Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<html lang='ko'>
<body className={`${notoSansKr.variable} antialiased`}>{children}</body>
</html>
);
Expand Down
12 changes: 12 additions & 0 deletions src/app/map/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import KakaoMap from '@/components/map/KakaoMap';
import { KakaoProvider } from '@/context/KakaoContext';

export default function Map() {
return (
<>
<KakaoProvider>
<KakaoMap />
</KakaoProvider>
</>
);
}
2 changes: 1 addition & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export default function Home() {
return <div className="text-red-400">홈페이지입니다.</div>;
return <div className='text-red-400'>홈페이지입니다.</div>;
}
58 changes: 58 additions & 0 deletions src/components/map/KakaoMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
'use client';

import { useKakao } from '@/context/KakaoContext';
import { useEffect } from 'react';
import SearchList from './SearchList';

declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
kakao: any;
}
}

/**
* 스크립트가 로드 될 때 KakaoAPI 함수가 실행됩니다.
* @returns 카카오 지도 컴포넌트
*/
export default function KakaoMap() {
const { setKakao } = useKakao();

useEffect(() => {
const kakaoMapScript = document.createElement('script');
kakaoMapScript.async = false;
kakaoMapScript.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_KAKAO_API_KEY}&autoload=false&libraries=services`;
document.head.appendChild(kakaoMapScript);

const onLoadKakaoAPI = () => {
window.kakao.maps.load(() => {
const container = document.getElementById('map');
const options = {
center: new window.kakao.maps.LatLng(33.450701, 126.570667),
level: 3,
};

setKakao({
map: new window.kakao.maps.Map(container, options),
place: new window.kakao.maps.services.Places(),
infowindow: new window.kakao.maps.InfoWindow({ zIndex: 1 }),
});
});
};

kakaoMapScript.addEventListener('load', onLoadKakaoAPI);

return () => {
kakaoMapScript.removeEventListener('load', onLoadKakaoAPI);
document.head.removeChild(kakaoMapScript);
};
}, []);

return (
<>
<div id='map' className='relative size-full'>
<SearchList />
</div>
</>
);
}
126 changes: 126 additions & 0 deletions src/components/map/SearchList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
'use client';

import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { useKakao } from '@/context/KakaoContext';
import { placesSearchCB } from '@/lib/kakaoMap';
import { useState } from 'react';
import { Card, CardContent, CardHeader } from '../ui/card';

const searchKeywordSchema = z.object({
keyword: z.string().min(1, {
message: '키워드를 입력해주세요',
}),
});

interface Facility {
id: string;
address_name: string;
place_name: string;
place_url: string;
phone: string;
}

export default function SearchList() {
const { kakao } = useKakao();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [list, setList] = useState<any>();

const form = useForm<z.infer<typeof searchKeywordSchema>>({
resolver: zodResolver(searchKeywordSchema),
defaultValues: {
keyword: '',
},
mode: 'onChange',
});

const onSubmit = (values: z.infer<typeof searchKeywordSchema>) => {
if (kakao) {
kakao.place.keywordSearch(
values.keyword,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data: any, status: any, pagination: any) => {
placesSearchCB(data, status, pagination, kakao, setList);
},
);
}
};

const prevClick = () => {
list?.pagination.prevPage();
};

const nextClick = () => {
list?.pagination.nextPage();
};

return (
<div className='absolute left-4 top-4 z-10 flex w-[300px] flex-col gap-4 rounded-sm bg-white px-4 py-2 opacity-80'>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className='flex flex-col gap-4'
>
<FormField
control={form.control}
name='keyword'
render={({ field }) => {
return (
<FormItem>
<FormLabel className='text-gray-900'>키워드</FormLabel>
<FormControl>
<Input {...field} placeholder='키워드 입력' type='text' />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<Button>검색</Button>
</form>
</Form>
<div className='flex flex-col gap-3'>
<div className='flex gap-2'>
<Button onClick={prevClick} className='flex-1'>
이전
</Button>
<Button onClick={nextClick} className='flex-1'>
이후
</Button>
</div>
<div className='flex h-[300px] flex-col gap-2 overflow-y-scroll'>
{list?.data &&
list.data.map((facility: Facility) => (
<FacilityCard key={facility.id} data={facility} />
))}
</div>
</div>
</div>
);
}

function FacilityCard({ data }: { data: Facility }) {
return (
<a href={data.place_url} rel='noopenner noreferrer' target='_blank'>
<Card>
<CardHeader className='font-bold'>{data.place_name}</CardHeader>
<CardContent className='flex flex-col gap-2'>
<p className='text-sm'>주소 : {data.address_name}</p>
<p className='text-xs'>전화번호 : {data.phone}</p>
</CardContent>
</Card>
</a>
);
}
2 changes: 1 addition & 1 deletion src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
Expand Down
35 changes: 35 additions & 0 deletions src/context/KakaoContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use client';

import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useContext,
useState,
} from 'react';

interface KakaoContextType {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
kakao: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setKakao: Dispatch<SetStateAction<any>>;
}

const KakaoContext = createContext<KakaoContextType | null>(null);

export const KakaoProvider = ({ children }: { children: ReactNode }) => {
const [kakao, setKakao] = useState(null);

return (
<KakaoContext.Provider value={{ kakao, setKakao }}>
{children}
</KakaoContext.Provider>
);
};

export const useKakao = () => {
const context = useContext(KakaoContext);
if (!context) throw new Error('Kakao Provider안에서만 사용해주세요');
return context;
};
46 changes: 46 additions & 0 deletions src/lib/kakaoMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export const placesSearchCB = (
data: any,
status: any,
pagination: any,
kakao: any,
setList: any,
) => {
if (status === window.kakao.maps.services.Status.OK) {
document.querySelectorAll('div').forEach((div) => {
if (div.style.margin === '-39px 0px 0px -14px') {
div.remove();
}
});

const bounds = new window.kakao.maps.LatLngBounds();

for (let i = 0; i < data.length; i++) {
displayMarker(data[i], kakao);
bounds.extend(new window.kakao.maps.LatLng(data[i].y, data[i].x));
}

kakao.map.setBounds(bounds);
setList({ data, pagination });
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
alert('검색 결과가 존재하지 않습니다.');
return;
} else {
alert('검색 결과 중 오류가 발생했습니다.');
return;
}
};

const displayMarker = (place: any, kakao: any) => {
const marker = new window.kakao.maps.Marker({
map: kakao.map,
position: new window.kakao.maps.LatLng(place.y, place.x),
});

window.kakao.maps.event.addListener(marker, 'click', () => {
kakao.infowindow.setContent(
'<div style="padding:5px;font-size:12px;">' + place.place_name + '</div>',
);
kakao.infowindow.open(kakao.map, marker);
});
};