-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] : 카카오 지도 기반 노래방 위치 검색 및 즐겨찾기 기능 추가 (#224) #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // Supabase table required: | ||
| // CREATE TABLE karaoke_favorites ( | ||
| // id uuid DEFAULT gen_random_uuid() PRIMARY KEY, | ||
| // user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE, | ||
| // place_id text NOT NULL, | ||
| // place_name text NOT NULL, | ||
| // address text NOT NULL, | ||
| // lat float8 NOT NULL, | ||
| // lng float8 NOT NULL, | ||
| // created_at timestamptz DEFAULT now(), | ||
| // UNIQUE(user_id, place_id) | ||
| // ); | ||
|
|
||
| import { NextResponse } from 'next/server'; | ||
|
|
||
| import createClient from '@/lib/supabase/server'; | ||
| import { ApiResponse } from '@/types/apiRoute'; | ||
| import { KaraokeFavorite } from '@/types/karaoke'; | ||
| import { getAuthenticatedUser } from '@/utils/getAuthenticatedUser'; | ||
|
|
||
| export async function GET(): Promise<NextResponse<ApiResponse<KaraokeFavorite[]>>> { | ||
| try { | ||
| const supabase = await createClient(); | ||
| const userId = await getAuthenticatedUser(supabase); | ||
|
|
||
| const { data, error } = await supabase | ||
| .from('karaoke_favorites') | ||
| .select('*') | ||
| .eq('user_id', userId) | ||
| .order('created_at', { ascending: false }); | ||
|
|
||
| if (error) throw error; | ||
|
|
||
| return NextResponse.json({ success: true, data: data ?? [] }); | ||
| } catch (error) { | ||
| if (error instanceof Error && error.cause === 'auth') { | ||
| return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 }); | ||
| } | ||
| return NextResponse.json({ success: false, error: 'Failed to get favorites' }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| export async function POST(request: Request): Promise<NextResponse<ApiResponse<void>>> { | ||
| try { | ||
| const supabase = await createClient(); | ||
| const userId = await getAuthenticatedUser(supabase); | ||
|
|
||
| const { placeId, placeName, address, lat, lng } = await request.json(); | ||
|
|
||
| const { error } = await supabase.from('karaoke_favorites').insert({ | ||
| user_id: userId, | ||
| place_id: placeId, | ||
| place_name: placeName, | ||
| address, | ||
| lat, | ||
| lng, | ||
| }); | ||
|
|
||
| if (error) throw error; | ||
|
|
||
| return NextResponse.json({ success: true }); | ||
| } catch (error) { | ||
| if (error instanceof Error && error.cause === 'auth') { | ||
| return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 }); | ||
| } | ||
| return NextResponse.json({ success: false, error: 'Failed to add favorite' }, { status: 500 }); | ||
| } | ||
| } | ||
|
|
||
| export async function DELETE(request: Request): Promise<NextResponse<ApiResponse<void>>> { | ||
| try { | ||
| const supabase = await createClient(); | ||
| const userId = await getAuthenticatedUser(supabase); | ||
|
|
||
| const { placeId } = await request.json(); | ||
|
|
||
| const { error } = await supabase | ||
| .from('karaoke_favorites') | ||
| .delete() | ||
| .match({ user_id: userId, place_id: placeId }); | ||
|
|
||
| if (error) throw error; | ||
|
|
||
| return NextResponse.json({ success: true }); | ||
| } catch (error) { | ||
| if (error instanceof Error && error.cause === 'auth') { | ||
| return NextResponse.json({ success: false, error: 'User not authenticated' }, { status: 401 }); | ||
| } | ||
| return NextResponse.json({ success: false, error: 'Failed to delete favorite' }, { status: 500 }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| 'use client'; | ||
|
|
||
| import KakaoMap from '@/components/KakaoMap'; | ||
|
|
||
| export default function MapPage() { | ||
| return ( | ||
| <div className="flex h-full flex-col"> | ||
| <h1 className="mb-3 text-lg font-bold">노래방 찾기</h1> | ||
| <KakaoMap /> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,108 @@ | ||
| 'use client'; | ||
|
|
||
| import Script from 'next/script'; | ||
| import { toast } from 'sonner'; | ||
|
|
||
| import { Button } from '@/components/ui/button'; | ||
| import { useKakaoMap } from '@/hooks/useKakaoMap'; | ||
| import { | ||
| useAddKaraokeFavoriteMutation, | ||
| useDeleteKaraokeFavoriteMutation, | ||
| useKaraokeFavoritesQuery, | ||
| } from '@/queries/karaokeQuery'; | ||
| import useAuthStore from '@/stores/useAuthStore'; | ||
|
|
||
| export default function KakaoMap() { | ||
| const { mapRef, selectedPlace, setIsScriptLoaded } = useKakaoMap(); | ||
|
|
||
| const { isAuthenticated } = useAuthStore(); | ||
| const { data: favorites = [] } = useKaraokeFavoritesQuery(isAuthenticated); | ||
| const addFavorite = useAddKaraokeFavoriteMutation(); | ||
| const deleteFavorite = useDeleteKaraokeFavoriteMutation(); | ||
|
|
||
| const favoriteIds = new Set(favorites.map(f => f.place_id)); | ||
|
|
||
| const handleFavoriteToggle = () => { | ||
| if (!isAuthenticated) { | ||
| toast.error('로그인이 필요합니다.'); | ||
| return; | ||
| } | ||
| if (!selectedPlace) return; | ||
|
|
||
| const isFav = favoriteIds.has(selectedPlace.id); | ||
| if (isFav) { | ||
| deleteFavorite.mutate(selectedPlace.id, { | ||
| onSuccess: () => toast.success(`${selectedPlace.place_name} 즐겨찾기 삭제`), | ||
| }); | ||
| } else { | ||
| addFavorite.mutate( | ||
| { | ||
| placeId: selectedPlace.id, | ||
| placeName: selectedPlace.place_name, | ||
| address: selectedPlace.road_address_name || selectedPlace.address_name, | ||
| lat: Number(selectedPlace.y), | ||
| lng: Number(selectedPlace.x), | ||
| }, | ||
| { onSuccess: () => toast.success(`${selectedPlace.place_name} 즐겨찾기 추가`) }, | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <> | ||
| <Script | ||
| src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_MAP_KEY}&libraries=services&autoload=false`} | ||
| onLoad={() => setIsScriptLoaded(true)} | ||
| /> | ||
|
|
||
| <div className="flex h-full flex-col gap-3"> | ||
| <div ref={mapRef} className="w-full flex-1 rounded-lg" style={{ minHeight: '60vh' }} /> | ||
|
|
||
| {selectedPlace && ( | ||
| <div className="bg-card rounded-lg border p-4"> | ||
| <p className="font-semibold">{selectedPlace.place_name}</p> | ||
| <p className="text-muted-foreground mt-1 text-sm"> | ||
| {selectedPlace.road_address_name || selectedPlace.address_name} | ||
| </p> | ||
| {selectedPlace.phone && ( | ||
| <p className="text-muted-foreground text-sm">{selectedPlace.phone}</p> | ||
| )} | ||
| <Button | ||
| className="mt-3 w-full" | ||
| variant={favoriteIds.has(selectedPlace.id) ? 'destructive' : 'default'} | ||
| onClick={handleFavoriteToggle} | ||
| disabled={addFavorite.isPending || deleteFavorite.isPending} | ||
| > | ||
| {favoriteIds.has(selectedPlace.id) ? '즐겨찾기 삭제' : '즐겨찾기 추가'} | ||
| </Button> | ||
| </div> | ||
| )} | ||
|
|
||
| {favorites.length > 0 && ( | ||
| <div className="bg-card rounded-lg border p-4"> | ||
| <p className="mb-2 font-semibold">즐겨찾기 ({favorites.length})</p> | ||
| <ul className="flex flex-col gap-2"> | ||
| {favorites.map(fav => ( | ||
| <li key={fav.place_id} className="flex items-center justify-between text-sm"> | ||
| <span>{fav.place_name}</span> | ||
| <Button | ||
| size="sm" | ||
| variant="ghost" | ||
| className="text-muted-foreground h-auto px-2 py-1 text-xs" | ||
| onClick={() => | ||
| deleteFavorite.mutate(fav.place_id, { | ||
| onSuccess: () => toast.success(`${fav.place_name} 삭제`), | ||
| }) | ||
| } | ||
| > | ||
| 삭제 | ||
| </Button> | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| </div> | ||
| )} | ||
|
Comment on lines
+81
to
+104
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Karaoke favorites page missing There is no dedicated page/route that displays the user's saved karaoke favorites; favorites are only shown inline within the map component. This does not meet the requirement for a standalone favorites list page. Agent Prompt
|
||
| </div> | ||
| </> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| 'use client'; | ||
|
|
||
| /* eslint-disable @typescript-eslint/no-explicit-any */ | ||
|
|
||
| import { useCallback, useEffect, useRef, useState } from 'react'; | ||
|
|
||
| import { KakaoPlace } from '@/types/karaoke'; | ||
|
|
||
| declare global { | ||
| interface Window { | ||
| kakao: any; // eslint-disable-line @typescript-eslint/no-explicit-any | ||
| } | ||
| } | ||
|
|
||
| export function useKakaoMap() { | ||
| const mapRef = useRef<HTMLDivElement>(null); | ||
| const [selectedPlace, setSelectedPlace] = useState<KakaoPlace | null>(null); | ||
| const [isScriptLoaded, setIsScriptLoaded] = useState(false); | ||
|
|
||
| const searchNearbyKaraoke = useCallback((map: any, lat: number, lng: number) => { | ||
| const ps = new window.kakao.maps.services.Places(); | ||
| const center = new window.kakao.maps.LatLng(lat, lng); | ||
| const infowindow = new window.kakao.maps.InfoWindow({ zIndex: 1 }); | ||
|
|
||
| ps.keywordSearch( | ||
| '노래방', | ||
| (data: KakaoPlace[], status: string) => { | ||
| if (status !== window.kakao.maps.services.Status.OK) return; | ||
|
|
||
| data.forEach(place => { | ||
| const position = new window.kakao.maps.LatLng(Number(place.y), Number(place.x)); | ||
| const marker = new window.kakao.maps.Marker({ map, position }); | ||
|
|
||
| window.kakao.maps.event.addListener(marker, 'click', () => { | ||
| infowindow.close(); | ||
| setSelectedPlace(place); | ||
| infowindow.setContent( | ||
| `<div style="padding:6px 10px;font-size:13px;font-weight:bold;">${place.place_name}</div>`, | ||
| ); | ||
| infowindow.open(map, marker); | ||
| }); | ||
|
Comment on lines
+34
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Infowindow xss risk useKakaoMap()이 Kakao InfoWindow content를 HTML 문자열로 만들면서 외부 데이터인 place.place_name을 그대로 보간해 DOM에 주입합니다. 악의적인 place_name 값이 들어오면 InfoWindow 영역에서 스크립트/HTML 실행(XSS)로 이어질 수 있습니다. Agent Prompt
Comment on lines
+34
to
+41
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 3. Kakao infowindow xss useKakaoMap()에서 외부(Kakao Places API)로부터 온 place_name을 HTML 문자열에 그대로 삽입해 InfoWindow.setContent()에 넘겨 DOM 기반 XSS가 가능합니다. 악성 place_name이 포함되면 스크립트 실행/세션 탈취 등으로 이어질 수 있습니다. Agent Prompt
|
||
| }); | ||
| }, | ||
| { | ||
| location: center, | ||
| radius: 1000, | ||
| sort: window.kakao.maps.services.SortBy.DISTANCE, | ||
| }, | ||
| ); | ||
|
Comment on lines
+20
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Kakao places called in browser Client code directly loads the Kakao Maps SDK and calls Places().keywordSearch() in the browser, which is an external API interaction outside /api/*. This violates the BFF requirement and bypasses centralized auth/validation and request controls. Agent Prompt
Comment on lines
+20
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. No place search input The new /map UI only performs a fixed keyword search ('노래방') around the current/fallback
location and does not provide a user-driven place search input/results UI. This fails the
requirement for a location search page that includes place search functionality.
Agent Prompt
|
||
| }, []); | ||
|
|
||
| const initMap = useCallback(() => { | ||
| if (!mapRef.current || !window.kakao) return; | ||
|
|
||
| window.kakao.maps.load(() => { | ||
| const options = { | ||
| center: new window.kakao.maps.LatLng(37.5665, 126.978), | ||
| level: 4, | ||
| }; | ||
| const map = new window.kakao.maps.Map(mapRef.current, options); | ||
|
|
||
| if (navigator.geolocation) { | ||
| navigator.geolocation.getCurrentPosition( | ||
| position => { | ||
| const lat = position.coords.latitude; | ||
| const lng = position.coords.longitude; | ||
| map.setCenter(new window.kakao.maps.LatLng(lat, lng)); | ||
| searchNearbyKaraoke(map, lat, lng); | ||
| }, | ||
| () => { | ||
| searchNearbyKaraoke(map, 37.5665, 126.978); | ||
| }, | ||
| ); | ||
| } else { | ||
| searchNearbyKaraoke(map, 37.5665, 126.978); | ||
| } | ||
| }); | ||
| }, [searchNearbyKaraoke]); | ||
|
|
||
| useEffect(() => { | ||
| if (isScriptLoaded) { | ||
| initMap(); | ||
| } | ||
| }, [isScriptLoaded, initMap]); | ||
|
|
||
| return { mapRef, selectedPlace, isScriptLoaded, setIsScriptLoaded }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { ApiResponse } from '@/types/apiRoute'; | ||
| import { KaraokeFavorite } from '@/types/karaoke'; | ||
|
|
||
| import { instance } from './client'; | ||
|
|
||
| export async function getKaraokeFavorites() { | ||
| const response = await instance.get<ApiResponse<KaraokeFavorite[]>>('/karaoke/favorites'); | ||
| return response.data; | ||
| } | ||
|
|
||
| export async function postKaraokeFavorite(body: { | ||
| placeId: string; | ||
| placeName: string; | ||
| address: string; | ||
| lat: number; | ||
| lng: number; | ||
| }) { | ||
| const response = await instance.post<ApiResponse<void>>('/karaoke/favorites', body); | ||
| return response.data; | ||
| } | ||
|
|
||
| export async function deleteKaraokeFavorite(body: { placeId: string }) { | ||
| const response = await instance.delete<ApiResponse<void>>('/karaoke/favorites', { data: body }); | ||
| return response.data; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; | ||
|
|
||
| import { | ||
| deleteKaraokeFavorite, | ||
| getKaraokeFavorites, | ||
| postKaraokeFavorite, | ||
| } from '@/lib/api/karaokeMap'; | ||
|
|
||
| export function useKaraokeFavoritesQuery(isAuthenticated: boolean) { | ||
| return useQuery({ | ||
| queryKey: ['karaokeFavorites'], | ||
| queryFn: async () => { | ||
| const response = await getKaraokeFavorites(); | ||
| if (!response.success) return []; | ||
| return response.data ?? []; | ||
| }, | ||
| enabled: isAuthenticated, | ||
| }); | ||
| } | ||
|
|
||
| export function useAddKaraokeFavoriteMutation() { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (body: { | ||
| placeId: string; | ||
| placeName: string; | ||
| address: string; | ||
| lat: number; | ||
| lng: number; | ||
| }) => postKaraokeFavorite(body), | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ['karaokeFavorites'] }); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| export function useDeleteKaraokeFavoriteMutation() { | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| return useMutation({ | ||
| mutationFn: (placeId: string) => deleteKaraokeFavorite({ placeId }), | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ queryKey: ['karaokeFavorites'] }); | ||
| }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
2. Claude.md missing map docs
📘 Rule violation⚙ MaintainabilityAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools