From cdebcc673ce3d3d22ad016608686157f897da24c Mon Sep 17 00:00:00 2001 From: GulSam00 Date: Thu, 7 May 2026 11:34:36 +0900 Subject: [PATCH] =?UTF-8?q?feat=20:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EA=B8=B0=EB=B0=98=20=EB=85=B8=EB=9E=98?= =?UTF-8?q?=EB=B0=A9=20=EC=9C=84=EC=B9=98=20=EA=B2=80=EC=83=89=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/Footer.tsx | 4 +- .../src/app/api/karaoke/favorites/route.ts | 91 +++++++++++++++ apps/web/src/app/map/page.tsx | 12 ++ apps/web/src/auth.tsx | 1 + apps/web/src/components/KakaoMap.tsx | 108 ++++++++++++++++++ apps/web/src/hooks/useKakaoMap.ts | 87 ++++++++++++++ apps/web/src/lib/api/karaokeMap.ts | 25 ++++ apps/web/src/queries/karaokeQuery.ts | 47 ++++++++ apps/web/src/stores/useFooterAnimateStore.ts | 2 +- apps/web/src/types/karaoke.ts | 21 ++++ 10 files changed, 394 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/api/karaoke/favorites/route.ts create mode 100644 apps/web/src/app/map/page.tsx create mode 100644 apps/web/src/components/KakaoMap.tsx create mode 100644 apps/web/src/hooks/useKakaoMap.ts create mode 100644 apps/web/src/lib/api/karaokeMap.ts create mode 100644 apps/web/src/queries/karaokeQuery.ts create mode 100644 apps/web/src/types/karaoke.ts diff --git a/apps/web/src/Footer.tsx b/apps/web/src/Footer.tsx index 6bcf065..20a9ca2 100644 --- a/apps/web/src/Footer.tsx +++ b/apps/web/src/Footer.tsx @@ -16,11 +16,9 @@ interface Navigation { const navigation: Navigation[] = [ { name: '최신 곡', href: '/recent', key: 'RECENT' }, - { name: '부를 곡', href: '/tosing', key: 'TOSING' }, { name: '검색', href: '/', key: 'SEARCH' }, - - { name: '인기곡', href: '/popular', key: 'POPULAR' }, + { name: '지도', href: '/map', key: 'MAP' }, { name: '정보', href: '/info', key: 'INFO' }, ]; diff --git a/apps/web/src/app/api/karaoke/favorites/route.ts b/apps/web/src/app/api/karaoke/favorites/route.ts new file mode 100644 index 0000000..652eb1a --- /dev/null +++ b/apps/web/src/app/api/karaoke/favorites/route.ts @@ -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>> { + 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>> { + 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>> { + 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 }); + } +} diff --git a/apps/web/src/app/map/page.tsx b/apps/web/src/app/map/page.tsx new file mode 100644 index 0000000..8326c63 --- /dev/null +++ b/apps/web/src/app/map/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import KakaoMap from '@/components/KakaoMap'; + +export default function MapPage() { + return ( +
+

노래방 찾기

+ +
+ ); +} diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx index ec511c0..33c020d 100644 --- a/apps/web/src/auth.tsx +++ b/apps/web/src/auth.tsx @@ -13,6 +13,7 @@ const ALLOW_PATHS = [ '/recent', '/tosing', '/update-password', + '/map', ]; export default function AuthProvider({ children }: { children: React.ReactNode }) { diff --git a/apps/web/src/components/KakaoMap.tsx b/apps/web/src/components/KakaoMap.tsx new file mode 100644 index 0000000..a73002f --- /dev/null +++ b/apps/web/src/components/KakaoMap.tsx @@ -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 ( + <> +