Skip to content
4 changes: 2 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ const pretendard = localFont({

export const metadata: Metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'https://www.mingling.kr'),
title: '밍글링 - 어디서 만날지, 고민 시간을 줄여드려요',
title: '밍글링 - 중간 위치로 만날 곳 정하기',
description:
'퇴근 후 모임, 주말 약속까지. 서울 어디서든 모두가 비슷하게 도착하는 마법의 장소를 찾아드려요.',
'퇴근 후 모임, 주말 약속까지! 서울 어디서든 모두가 비슷하게 도착하는 마법의 장소를 찾아드려요.',
};

export default function RootLayout({
Expand Down
143 changes: 72 additions & 71 deletions app/recommend/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
'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';

function RecommendContent() {
const router = useRouter();
const searchParams = useSearchParams();
const meetingId = searchParams.get('meetingId') || '';
const meetingId = searchParams.get(`meetingId`) || '';
const midPlace = searchParams.get('midPlace') || '';
const lat = searchParams.get('lat');
const lng = searchParams.get('lng');
Expand All @@ -21,23 +21,25 @@ function RecommendContent() {
// 현재 선택된 장소 ID (기본값: 첫 번째)
const [selectedPlaceId, setSelectedPlaceId] = useState<number>(1);

// 선택된 카테고리 (기본값: localStorage의 meetingCategory)
const [selectedCategory, setSelectedCategory] = useState<string>(() => {
// 모임 카테고리 가져오기 (localStorage에서 캐싱된 값 사용)
const meetingCategory = useMemo(() => {
const cachedCategory = localStorage.getItem(`meeting_${meetingId}_category`);
return cachedCategory || '';
});
if (cachedCategory) {
return cachedCategory;
}

// 카테고리 변경 핸들러
const handleCategoryChange = (category: string) => {
setSelectedCategory(category);
setSelectedPlaceId(1); // 카테고리 변경 시 첫 번째 장소 선택
};
return '';
}, [meetingId]);
Comment on lines +24 to +32
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

useMemo 내에서 localStorage 접근은 SSR 안전성 문제가 있습니다.

localStorage는 브라우저 전용 API입니다. 현재 Suspense + useSearchParams 조합으로 클라이언트 렌더링이 보장될 수 있지만, 이 의존성은 암묵적이고 깨지기 쉽습니다. 컴포넌트가 다른 곳으로 이동하거나 Suspense 래퍼가 제거되면 SSR 시 ReferenceError가 발생합니다.

또한 useMemo는 순수 계산용이며, 외부 저장소 읽기에는 useState + useEffect 또는 useSyncExternalStore가 더 적절합니다.

추가로, localStorage에 해당 키가 없으면 meetingCategory가 빈 문자열이 되어 useRecommendenabled 조건(!!category)이 false가 됩니다. 이 경우 사용자는 "추천 장소가 없습니다"를 보게 되며 복구 방법이 없습니다.

🔧 useState + useEffect 패턴으로 수정 제안
- // 모임 카테고리 가져오기 (localStorage에서 캐싱된 값 사용)
- const meetingCategory = useMemo(() => {
-   const cachedCategory = localStorage.getItem(`meeting_${meetingId}_category`);
-   if (cachedCategory) {
-     return cachedCategory;
-   }
-
-   return '';
- }, [meetingId]);
+ // 모임 카테고리 가져오기 (localStorage에서 캐싱된 값 사용)
+ const [meetingCategory, setMeetingCategory] = useState('');
+
+ useEffect(() => {
+   const cachedCategory = localStorage.getItem(`meeting_${meetingId}_category`);
+   if (cachedCategory) {
+     setMeetingCategory(cachedCategory);
+   }
+ }, [meetingId]);

useEffect를 사용하면 SSR 안전성이 보장되고, localStorage 접근이 클라이언트 마운트 이후에만 발생합니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 모임 카테고리 가져오기 (localStorage에서 캐싱된 값 사용)
const meetingCategory = useMemo(() => {
const cachedCategory = localStorage.getItem(`meeting_${meetingId}_category`);
return cachedCategory || '';
});
if (cachedCategory) {
return cachedCategory;
}
// 카테고리 변경 핸들러
const handleCategoryChange = (category: string) => {
setSelectedCategory(category);
setSelectedPlaceId(1); // 카테고리 변경 시 첫 번째 장소 선택
};
return '';
}, [meetingId]);
// 모임 카테고리 가져오기 (localStorage에서 캐싱된 값 사용)
const [meetingCategory, setMeetingCategory] = useState('');
useEffect(() => {
const cachedCategory = localStorage.getItem(`meeting_${meetingId}_category`);
if (cachedCategory) {
setMeetingCategory(cachedCategory);
}
}, [meetingId]);
🤖 Prompt for AI Agents
In `@app/recommend/page.tsx` around lines 24 - 32, The current useMemo block
reading localStorage (meetingCategory) is SSR-unsafe and misuses useMemo;
replace it with a client-only state pattern: create a useState for
meetingCategory and populate it inside a useEffect that reads
localStorage.getItem(`meeting_${meetingId}_category`) (guarding on meetingId) so
the read happens only on mount, and ensure that when no key exists you set a
sensible fallback (e.g., undefined or null) rather than '', so the dependent
hook useRecommend (its enabled check !!category) can behave correctly; update
any references to meetingCategory accordingly.


// 장소 추천 API 호출
const { data: recommendData, isLoading, isError } = useRecommend({
const {
data: recommendData,
isLoading,
isError,
} = useRecommend({
meetingId,
midPlace,
category: selectedCategory,
category: meetingCategory,
page: 1,
size: 15,
});
Expand All @@ -61,7 +63,6 @@ function RecommendContent() {
}));
}, [recommendData]);


const handleBack = () => {
router.back();
};
Expand Down Expand Up @@ -89,7 +90,7 @@ function RecommendContent() {
</div>

{/* 모바일 전용 지도 (작게 표시) */}
<div className="bg-gray-1 relative block aspect-video h-93.5 md:hidden">
<div className="bg-gray-1 relative block h-93.5 md:hidden">
<KakaoMapRecommend
className="h-full w-full"
midPlace={midPlace}
Expand All @@ -98,8 +99,6 @@ function RecommendContent() {
places={places}
selectedPlaceId={selectedPlaceId}
onSelectPlace={setSelectedPlaceId}
selectedCategory={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
</div>

Expand All @@ -118,64 +117,68 @@ function RecommendContent() {
) : (
<div className="flex flex-col gap-4 md:gap-5">
{places.map((place) => (
<div
key={place.id}
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-gray-2 hover:bg-gray-1 bg-white'
}`}
>
{/* 상단: 이름 및 카테고리 */}
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 px-0.5 flex-1 min-w-0">
<span className="text-gray-8 text-xl font-semibold break-words">{place.name}</span>
<span className="bg-gray-2 text-gray-7 shrink-0 rounded px-2 py-px text-sm font-semibold">
{place.category}
</span>
<div
key={place.id}
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-gray-2 hover:bg-gray-1 bg-white'
}`}
>
Comment on lines +120 to +128
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

선택/비선택 시 border 두께 변경으로 레이아웃 쉬프트 발생 가능.

선택된 카드는 border-2 (2px), 비선택 카드는 border (1px)로 렌더링됩니다. 1px 차이로 인해 카드 선택 시 미세한 레이아웃 점프가 발생할 수 있습니다.

🔧 수정 제안: 항상 border-2를 유지하고 색상만 변경
                    className={`flex cursor-pointer flex-col gap-2 rounded border p-4 ${
                      selectedPlaceId === place.id
-                        ? 'border-blue-5 border-2' // 선택 시 파란 테두리
-                        : 'border-gray-2 hover:bg-gray-1 bg-white'
+                        ? 'border-blue-5 border-2'
+                        : 'border-gray-2 hover:bg-gray-1 border-2 border-transparent bg-white'
                    }`}

또는 ring을 사용하여 레이아웃에 영향을 주지 않는 방식도 고려해 볼 수 있습니다.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
key={place.id}
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-gray-2 hover:bg-gray-1 bg-white'
}`}
>
<div
key={place.id}
onClick={() => setSelectedPlaceId(place.id)}
className={`flex cursor-pointer flex-col gap-2 rounded border-2 p-4 ${
selectedPlaceId === place.id
? 'border-blue-5' // 선택 시 파란 테두리
: 'border-gray-2 hover:bg-gray-1 bg-white'
}`}
>
🤖 Prompt for AI Agents
In `@app/recommend/page.tsx` around lines 120 - 128, The current card toggles
between 'border-2' and 'border' causing a 1px layout shift; update the class
logic in the clickable card (the element using key={place.id}, onClick={() =>
setSelectedPlaceId(place.id)} and reading selectedPlaceId === place.id) so that
it always includes 'border-2' and only switches border color classes (e.g., from
'border-gray-2' to a selected color like 'border-blue-5') or alternatively
remove border thickness changes and apply a non-layout ring for selection;
adjust the conditional to change color/ring classes rather than toggling between
'border-2' and 'border'.

{/* 상단: 이름 및 카테고리 */}
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2 px-0.5">
<span className="text-gray-8 text-xl font-semibold break-words">
{place.name}
</span>
<span className="bg-gray-2 text-gray-7 shrink-0 rounded px-2 py-px text-sm font-semibold">
{place.category}
</span>
</div>
{/* 공유 아이콘 (이미지에 살짝 보임) */}
<button className="text-gray-5 shrink-0 cursor-pointer">
<Image src="/icon/gray_share.svg" alt="공유" width={24} height={24} />
</button>
Comment on lines +140 to +142
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

공유 버튼에 onClick 핸들러가 없습니다.

공유 아이콘 버튼이 렌더링되지만 클릭 시 아무 동작도 하지 않습니다. 기능이 아직 구현되지 않았다면 비활성화 상태로 표시하거나 숨기는 것이 좋습니다.

🤖 Prompt for AI Agents
In `@app/recommend/page.tsx` around lines 140 - 142, The share button currently
rendered as <button className="text-gray-5 shrink-0 cursor-pointer"> with the
Image child has no onClick handler so it does nothing; add a click handler
(e.g., implement a handleShare function in app/recommend/page.tsx and assign it
to the button's onClick) that performs the share logic (or, if the feature isn't
ready, set the button to disabled and add aria-disabled and a
tooltip/visually-hidden label or hide the button entirely) and ensure
accessibility attributes (aria-label) are present to describe the action.

</div>
{/* 공유 아이콘 (이미지에 살짝 보임) */}
<button className="text-gray-5 cursor-pointer shrink-0">
<Image src="/icon/gray_share.svg" alt="공유" width={24} height={24} />
</button>
</div>

{/* 설명 */}
<p className="text-gray-6 text-[16px]">{place.description}</p>
{/* 설명 */}
<p className="text-gray-6 text-[16px]">{place.description}</p>

{/* 전화번호 */}
<div className="text-gray-8 flex items-center gap-2 text-[16px]">
<Image src="/icon/phone.svg" alt="전화" width={20} height={20} />
{place.phone}
</div>

{/* 주소 정보 */}
<div className="flex flex-col gap-2 text-[12px]">
<div className="flex items-start gap-2.5">
<span className="text-gray-5 border-gray-1 m-0.5 shrink-0 rounded border px-2 py-px">
지번
</span>
<span className="text-gray-8 text-[16px] break-words">{place.address}</span>
{/* 전화번호 */}
<div className="text-gray-8 flex items-center gap-2 text-[16px]">
<Image src="/icon/phone.svg" alt="전화" width={20} height={20} />
{place.phone}
</div>
<div className="flex items-start gap-2">
<span className="text-gray-5 border-gray-1 shrink-0 rounded border px-2 py-px">
도로명
</span>
<span className="text-gray-8 text-[16px] break-words">{place.roadAddress}</span>

{/* 주소 정보 */}
<div className="flex flex-col gap-2 text-[12px]">
<div className="flex items-start gap-2.5">
<span className="text-gray-5 border-gray-1 m-0.5 shrink-0 rounded border px-2 py-px">
지번
</span>
<span className="text-gray-8 text-[16px] break-words">{place.address}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-gray-5 border-gray-1 shrink-0 rounded border px-2 py-px">
도로명
</span>
<span className="text-gray-8 text-[16px] break-words">
{place.roadAddress}
</span>
</div>
</div>
</div>

{/* 하단 버튼은 조건부 렌더링 */}
{selectedPlaceId === place.id ? (
<button
onClick={(e) => handleOpenKakaoMap(e, place.placeUrl)}
className="bg-gray-8 w-full rounded py-1 text-[15px] text-white"
>
카카오맵에서 보기
</button>
) : null}
</div>
{/* 하단 버튼은 조건부 렌더링 */}
{selectedPlaceId === place.id ? (
<button
onClick={(e) => handleOpenKakaoMap(e, place.placeUrl)}
className="bg-gray-8 w-full rounded py-1 text-[15px] text-white"
>
카카오맵에서 보기
</button>
) : null}
</div>
))}
</div>
)}
Expand All @@ -192,8 +195,6 @@ function RecommendContent() {
places={places}
selectedPlaceId={selectedPlaceId}
onSelectPlace={setSelectedPlaceId}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
/>
</section>
</div>
Expand Down
Loading