부트캠프 2차 프로젝트로 농구를 좋아하는 프로젝트 팀장님이 오랫동안 만들고 싶으셨던 농구 관련 서비스를 만들게 되었다. 기존 카페보다 개선된 UI, 기능을 만들고자 했고 모바일 사용자가 많을 것으로 예상되어 웹앱으로 구현했다.
🏀 슬램톡은 농구를 할 장소와 함께 할 친구를 찾을 수 있는 플랫폼으로 농구장 지도, 농구장 제보하기, 농구 메이트 찾기, 팀 매칭, 커뮤니티(대관 양도, 중고 거래 등), 채팅(1:1, 농구장 시설 채팅) 기능을 제공하는 서비스이다.
프론트엔드 3명, 백엔드 4명으로 구성되어 작업한 프로젝트로 기획부터 모두 참여하였다.
개발 기간: 24/01/11 ~ 24/2/22(프로젝트 발표) 이후 유지보수 중
> Github | Slam Talk Site
프론트엔드에서 리더를 맡게 되었고 내가 맡은 역할은 다음과 같다.
농구장 지도, 제보 기능
회원가입/로그인 관련(자체 회원가입, 소셜 로그인)
마이페이지, 유저 관리(프로필 표시, 유저 정보 수정)
개발 전반 환경설정(Next.js, GitHub Actions, Husky ...)
그 중 프로젝트 안에서도 핵심 기능이었던 농구장 지도 구현을 어떻게 했는지 설명하고자 한다.
사용 기술:
프레임워크, 언어: Next.js(14.0.4), TypeScript
사용 API: react-kakao-maps api
상태관리: React Query, Zustand
스타일링: TailwindCSS, NextUI
카카오 개발자 사이트에서 앱을 등록하고 JavaScript 키를 발급받아 .env.local에 환경 변수를 다음과 같이 관리해줬다. 항상 키는 노출되지 않도록 조심해야 하고 배포 환경에도 환경 변수를 등록해줘야 한다.
(+ 카카오 개발자 사이트 > 내 애플리케이션 > 앱설정 > 플랫폼에서 사이트 도메인도 등록해줘야 한다.)
NEXT_PUBLIC_MAP_KEY=실제키
head 태그 안에 다음과 같이 script를 삽입해주면 된다.
<html lang="en" className="light" suppressHydrationWarning>
<head>
<Script
strategy="beforeInteractive"
src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.NEXT_PUBLIC_MAP_KEY}&autoload=false&libraries=services,clusterer`}
/>
</head>
<body>
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<Providers>
{withoutHeader ? null : <Header />}
<main>{children}</main>
{pathname.includes('chatroom') ? null : <Footer />}
</Providers>
</QueryClientProvider>
</body>
</html>
처음에 kakao maps web api를 이용했지만 점점 기능이 많아지면서 react 환경에 적합하고 코드가 좀 더 간편, 문서화가 잘되어 있는 react-kakao-maps-sdk로 리팩토링했다. react 환경이라면 react-kakao-maps-sdk를 이용하는 것을 추천한다.
<MarkerClusterer
averageCenter // 클러스터에 포함된 마커들의 평균 위치를 클러스터 마커 위치로 설정
minLevel={8} // 클러스터 할 최소 지도 레벨
>
{courts?.map((court) => (
<>
<MapMarker
key={court.courtId}
position={{ lat: court.latitude, lng: court.longitude }}
image={{
src: '/icons/marker-img.png',
size: {
width: 41,
height: 48,
},
}}
clickable
onClick={() => {
setIsCourtDetailsOpen(true);
setSelectedCourtId(court.courtId);
}}
/>
<CustomOverlayMap
key={`overlay__${court.latitude}-${court.longitude}`}
position={{ lat: court.latitude, lng: court.longitude }}
yAnchor={2.6}
xAnchor={0.63}
>
<div className="ml-12 flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-black shadow-sm">
{court.courtName}
</div>
</CustomOverlayMap>
</>
))}
</MarkerClusterer>
피그마로 농구공 아이콘을 이용해 직접 마커를 만들었다. 이를 커스텀 오버레이를 이용해 커스텀 마커를 적용하고 위에 농구장명을 표시하도록 했다.
너무 많은 마커가 보이면 겹치므로 적당한 레벨에서 클러스트를 설정해준다.
처음 지도 접속시 빠른 데이터 표시를 위해 모든 농구장에 대한 간단 정보(위치 정보, 농구장명)만 불러와 마커를 표시해준다. 농구장 마커 클릭시 해당 농구장 id로 농구장 상세 정보를 요청해 표시한다. (map/courts, map/courts/{courtId} API가 분리되어 있다.)
농구장 마커를 클릭하면 해당 농구장 상세정보 모달이 열린다.
const userLocation = userLocationStore((state) => state.userLocation);
const [location, setLocation] = useState({
center: {
// 지도의 초기 위치
lat: userLocation ? userLocation.latitude : 37.5737,
lng: userLocation ? userLocation.longitude : 127.0484,
},
});
Geolocation API를 이용해 유저 위치 정보가 있으면 유저 현재 위치로 지도 처음 위치를 설정하고 없으면 서울로 처음 위치를 설정한다.
const handleMoveUserLocation = () => {
if (userLocation) {
const userPosition = new window.kakao.maps.LatLng(
userLocation.latitude,
userLocation.longitude
);
map.panTo(userPosition);
} else {
alert('위치 정보가 설정되지 않았습니다.');
}
};
농구장 제보 버튼 위에 location Icon을 누르면 현재 유저 위치로 지도가 이동한다.
당근처럼 유저 현재 위치 기반으로 서비스를 계속 발전시킬 계획으로 위치, 주소를 가져오는 유틸 함수를 만들고 위치, 주소 정보를 전역 상태로 저장했다.
const filterCourts = (
basketballCourts: BasketballCourts[],
keyword: string
) => {
const result = basketballCourts.filter(
(court) =>
court.courtName.includes(keyword) || court.address.includes(keyword)
);
return result;
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchKeyword(e.target.value);
};
const handleSearch = () => {
if (!map) return;
if (!searchKeyword) {
alert('검색어를 입력하세요.');
return;
}
const filteredCourts = filterCourts(courts || [], searchKeyword);
if (filteredCourts.length > 0) {
const firstCourt = filteredCourts[0];
const courtPosition = new window.kakao.maps.LatLng(
firstCourt.latitude,
firstCourt.longitude
);
map.panTo(courtPosition);
} else {
const ps = new window.kakao.maps.services.Places();
ps.keywordSearch(
searchKeyword,
(data: any, status: any) => {
if (status === window.kakao.maps.services.Status.OK) {
const firstResult = data[0];
if (firstResult) {
const { x, y } = firstResult;
const kakaoPosition = new window.kakao.maps.LatLng(y, x);
map.panTo(kakaoPosition);
}
} else if (status === window.kakao.maps.services.Status.ZERO_RESULT) {
alert('검색 결과가 존재하지 않습니다.');
} else if (status === window.kakao.maps.services.Status.ERROR) {
alert('검색 결과 중 오류가 발생했습니다.');
}
},
{ location: map.getCenter() }
);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
검색을 하면 현재 DB에 있는 농구장 정보(API 응답) 안에서 먼저 검색을 하고 검색 결과가 있다면 그 검색 결과 위치로 이동한다. DB 안에 없는 정보를 검색한다면 kakao maps api를 이용해 검색하고 그 결과에 따라 이동한다.
{isCourtDetailsOpen && (
<CourtDetails
courtId={selectedCourtId}
handleClose={() => setIsCourtDetailsOpen(false)}
/>
)}
'use client';
import React, { useState } from 'react';
import {
Button,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from '@nextui-org/react';
import { IoIosClose } from 'react-icons/io';
import { FaPhoneAlt, FaParking, FaTag, FaRegDotCircle } from 'react-icons/fa';
import Image from 'next/image';
import { FaLocationDot, FaClock, FaLightbulb } from 'react-icons/fa6';
import { PiChatsCircle } from 'react-icons/pi';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import getCourtDetails from '@/services/basketballCourt/getCourtDetails';
import { RiShareBoxFill } from 'react-icons/ri';
import Link from 'next/link';
import LocalStorage from '@/utils/localstorage';
import { CourtIcon } from './icons/CourtIcon';
import { HoopIcon } from './icons/HoopIcon';
import { FeeIcon } from './icons/FeeIcon';
import { InfoIcon } from './icons/InfoIcon';
import { WebsiteIcon } from './icons/WebsiteIcon';
interface CourtDetailsProps {
courtId: number;
handleClose: () => void;
}
const CourtDetails: React.FC<CourtDetailsProps> = ({
courtId,
handleClose,
}) => {
const router = useRouter();
const isLoggedIn = LocalStorage.getItem('isLoggedIn');
const [loginMsg, setLoginMsg] = useState('');
const [alertMsg, setAlertMsg] = useState('');
const [isTel, setIsTel] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const { error, data: selectedPlace } = useQuery({
queryKey: ['courtDetails', courtId],
queryFn: () => getCourtDetails(courtId),
});
if (error) {
console.log(error);
router.push('/map');
}
if (selectedPlace) {
const handlePhoneClick = () => {
if (selectedPlace.phoneNum) {
setAlertMsg(
`이 전화번호로 연결하시겠습니까? ${selectedPlace.phoneNum}`
);
setIsTel(true);
onOpen();
}
};
const handleCopyAddress = async () => {
if (selectedPlace.address) {
try {
await navigator.clipboard.writeText(selectedPlace.address);
setAlertMsg('주소가 복사되었습니다.');
onOpen();
} catch (copyError) {
console.error('주소 복사 중 오류 발생:', copyError);
setAlertMsg('주소를 복사하는 데 실패했습니다.');
onOpen();
}
}
};
const handleShareCourt = () => {
const shareUrl = `https://www.slam-talk.site/map/${courtId}`;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
setAlertMsg(
'해당 농구장 정보를 공유할 수 있는 링크가 복사되었습니다.'
);
onOpen();
})
.catch((err) => {
setAlertMsg('링크 복사에 실패했습니다.');
onOpen();
console.error('링크 복사 실패:', err);
});
};
const handleGoChatting = () => {
if (isLoggedIn === 'true') {
router.push(`/chatting/chatroom/${selectedPlace.chatroomId}`);
} else {
setLoginMsg('로그인 후 이용할 수 있는 서비스입니다.');
onOpen();
}
};
const handleCloseLoginModal = () => {
setLoginMsg('');
onClose();
};
const handleCloseAlert = () => {
setAlertMsg('');
onClose();
};
return (
<>
<title>슬램톡 | 농구장 지도</title>
<div
className={`min-w-md sm-h-full absolute inset-0 z-40 m-auto h-fit max-h-[calc(100vh-109px)] w-fit min-w-96 max-w-md overflow-y-auto rounded-lg
bg-background shadow-md transition-all duration-300 ease-in-out sm:min-w-full`}
>
<div className="w-full text-sm">
<div className="relative h-56 w-full sm:h-52">
{selectedPlace.photoUrl ? (
<Image
layout="fill"
objectFit="contain"
alt="농구장 사진"
src={selectedPlace.photoUrl}
/>
) : (
<Image
layout="fill"
alt="농구장 사진"
src="/images/basketball-court.svg"
/>
)}
<Button
isIconOnly
className="bg-gradient absolute right-2 top-2"
onClick={handleClose}
aria-label="Close"
>
<IoIosClose size={30} className="text-gray-600" />
</Button>
</div>
<div className="p-4">
<div className="flex justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-xl font-bold">
{selectedPlace.courtName}
</h2>
<span className="rounded-sm bg-gray-100 px-1 text-gray-500 dark:bg-gray-300 dark:text-gray-600">
{selectedPlace.indoorOutdoor}
</span>
</div>
<Button
color="primary"
radius="full"
size="md"
startContent={<PiChatsCircle />}
aria-label="시설 채팅 바로가기"
onClick={handleGoChatting}
>
시설 채팅
</Button>
</div>
<div className="my-2 flex w-full items-center justify-start gap-3">
<Button
size="sm"
aria-label="공유하기"
variant="bordered"
className="border-0 p-0"
radius="full"
startContent={<RiShareBoxFill />}
onClick={handleShareCourt}
>
공유하기
</Button>
<hr className="h-4 w-px bg-gray-300" />
<Button
size="sm"
variant="bordered"
startContent={<FaRegDotCircle />}
radius="full"
className="border-0 p-0"
onClick={() => {
window.open(
`https://map.kakao.com/link/to/${selectedPlace.courtName},${selectedPlace.latitude},${selectedPlace.longitude}`,
'_blank'
);
}}
>
길찾기
</Button>
</div>
<div className="flex justify-center " />
<hr className="w-90 my-4 h-px bg-gray-300" />
<div className="my-4 flex flex-col gap-4">
<div className="flex gap-2 align-middle">
<FaLocationDot
size={16}
className="dark:text-gray-20 text-gray-400"
/>
<span>{selectedPlace.address}</span>
<button type="button" onClick={handleCopyAddress}>
<span className="text-blue-500">복사</span>
</button>
</div>
<div className="flex gap-2 align-middle">
<FaClock
size={14}
className="text-gray-400 dark:text-gray-200"
/>
<span>
개방 시간:{' '}
<span className="text-rose-400">
{selectedPlace.openingHours}
</span>
</span>
</div>
<div className="flex gap-2 align-middle">
<FaPhoneAlt
size={15}
className="pointer-events-auto text-gray-400 dark:text-gray-200"
onClick={handlePhoneClick}
/>
<span>
{selectedPlace.phoneNum ? selectedPlace.phoneNum : '-'}
</span>
</div>
<div className="flex gap-2 align-middle">
<FeeIcon className="text-gray-400 dark:text-gray-200" />
<span className="text-info text-blue-500">
이용료: {selectedPlace.fee}
</span>
</div>
<div className="flex gap-2 align-middle">
<WebsiteIcon className="text-gray-400 dark:text-gray-200" />
<span className="text-blue-500">
{selectedPlace.website ? (
<Link href={selectedPlace.website} target="_blank">
{selectedPlace.website}
</Link>
) : (
'-'
)}
</span>
</div>
<div className="flex gap-2 align-middle">
<CourtIcon className="text-gray-400 dark:text-gray-200" />
<span className="font-medium">
코트 종류: {selectedPlace.courtType}
</span>
</div>
<div className="flex gap-2 align-middle">
<CourtIcon className="text-gray-400 dark:text-gray-200" />
<span className="font-medium">
코트 사이즈: {selectedPlace.courtSize}
</span>
</div>
<div className="flex gap-2 align-middle">
<HoopIcon className="text-gray-400 dark:text-gray-200" />
<span className="font-medium">
골대 수: {selectedPlace.hoopCount}
</span>
</div>
<div className="flex gap-2 align-middle">
<FaLightbulb
size={17}
className="text-gray-400 dark:text-gray-200"
/>
<span>야간 조명: {selectedPlace.nightLighting}</span>
</div>
<div className="flex gap-2 align-middle">
<FaParking
size={17}
className="text-gray-400 dark:text-gray-200"
/>
<span>주차: {selectedPlace.parkingAvailable}</span>
</div>
<div className="flex gap-2 align-middle text-sm">
<FaTag
size={17}
className="text-gray-400 dark:text-gray-200"
/>
<ul className="flex gap-2">
{selectedPlace.convenience
? selectedPlace.convenience.map(
(tag: string, idx: number) => (
<li
// eslint-disable-next-line react/no-array-index-key
key={idx}
className="rounded-sm bg-gray-100 px-1 text-gray-500 dark:bg-gray-300 dark:text-gray-600"
>
<span>{tag}</span>
</li>
)
)
: '-'}
</ul>
</div>
<div className="flex gap-2 align-middle">
<InfoIcon className="text-gray-400 dark:text-gray-200" />
<span className="text-sm">
{selectedPlace.additionalInfo
? selectedPlace.additionalInfo
: '-'}
</span>
</div>
</div>
</div>
</div>
</div>
{loginMsg && (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseLoginModal}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
농구장 시설 채팅
</ModalHeader>
<ModalBody>
<p>{loginMsg}</p>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="light"
onPress={handleCloseLoginModal}
>
닫기
</Button>
<Button
color="primary"
onPress={() => router.push('/login')}
>
로그인하러 가기
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)}
{alertMsg && (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseAlert}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
알림
</ModalHeader>
<ModalBody>
<p>{alertMsg}</p>
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={handleCloseAlert}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)}
{isTel && (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseAlert}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
전화 연결
</ModalHeader>
<ModalBody>
<p>{alertMsg}</p>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="light"
onPress={handleCloseLoginModal}
>
취소
</Button>
<Button
color="primary"
onClick={() => {
window.location.href = `tel:${selectedPlace.phoneNum}`;
}}
>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)}
</>
);
}
return null;
};
export default CourtDetails;
위치 기본 정보(사진, 주소, 홈페이지, 전화번호)와 해당 농구장의 자세한 농구장 특화 정보(개방 시간, 코트 종류, 코트 사이즈, 골대 수, 야간 조명, 주차 가능 여부, 실내외, 편의시설, 기타 정보)를 표시한다. 기존 지도에서 농구장만 표시하고 농구장 특화 정보를 추가해 농구를 하는 사람을 위한 지도를 만들었다. 농구장에 대한 정보 수집은 계속 이뤄지고 있다.
길찾기를 누르면 카카오맵으로 연결되며 해당 농구장으로 도착이 설정되어 바로 길찾기가 가능하다.
공유하기를 누르면 해당 농구장에 대한 url이 생겨 해당 농구장 정보를 공유할 수 있다.
시설 채팅 버튼을 누르면 해당 농구장 시설 채팅에 입장한다.
✏️ 알림 모달
- 프로젝트 중반 멘토링시 alert 대신 Next UI Modal을 이용해보라고 멘토님이 추천해주셔서 alert를 전반적으로 모달로 변경하였다.
- Next UI Modal을 사용하면 size, placement, backdrop 등을 지정해 쉽게 Modal을 구현할 수 있다. 우리는 팀 내에서 size="sm" placement="center"로 통일해 모달을 사용했다.
✏️ 한 페이지에서 여러 개 모달 구현하기
- 공식 문서에도 한 페이지에서 여러 모달을 띄우는 법을 안나와있어 msg 상태를 만들어 메세지를 작성해주고 close시 ''로 초기화 해줬다.
- 모달 제목까지 바꿔야 한다면 새로 모달을 만들어 isTel, isAlert 같이 모달을 판별할 수 있는 상태를 추가해 true일 때 해당 모달을 보여주는 식으로 여러 모달을 구현하였다.
- 개인적으로 모달은 분리해서 맨 아래 놓는게 보기 편해 <></>를 이용했다.
지도에 표시되지 않는 농구장을 유저가 직접 제보할 수 있는 기능이다.
제보하기 버튼을 누르면 지도 클릭시 마커 생성 이벤트가 발생하고 해당 마커를 클릭하면 위도, 경도, 주소 정보가 자동으로 채워져 해당 위치에 제보가 가능하다.
바로 이 기능에서 이벤트를 구현하다가 너무 복잡해져서 react-kakao-maps-sdk로 리팩토링하였다.
제보하기 버튼 누르면 지도 클릭 이벤트 활성화 -> 지도 클릭시 마커 생성 이벤트 -> 지도 클릭시마다 마커 움직이기 -> 마커 클릭시 제보 모달 열리기 -> 제보 모달 열리면 해당 마커 삭제, 지도 클릭 이벤트 비활성화 / 취소 버튼 누를시 생성된 마커 삭제, 지도 클릭 이벤트 비활성화
javascript보다 react sdk를 쓰니까 보다 간편하게 해결할 수 있었다. 해당 문서를 자세히 읽어보길 추천한다.
제보시 지도를 클릭한 위치에 위도, 경도, 주소 정보는 자동으로 들어간다.
const handleClickReport = (
_: kakao.maps.Map,
mouseEvent: MouseEventWithLatLng
) => {
const latlng = mouseEvent.latLng;
const lat = latlng.getLat();
const lng = latlng.getLng();
setCoord(
`클릭한 위치의 위도는 ${latlng.getLat()} 이고, 경도는 ${latlng.getLng()} 입니다`
);
console.log(coord);
if (mode === true) {
setPosition({
lat,
lng,
});
getAddressFromCoords(lat, lng)
.then(({ address, roadAddress }) => {
// 도로명 주소가 있으면 그 값을, 없으면 지번 주소를 사용
const finalAddress = roadAddress || address;
setClickPositionAddress(finalAddress);
})
.catch((error) => {
console.error('주소 정보를 가져오는데 실패했습니다.', error);
});
}
};
const handleClickReportMarker = () => {
setIsCourtReportOpen(true);
};
{isCourtReportDetailsOpen && (
<CourtReportDetails
courtId={selectedCourtReportId}
onClose={() => setIsCourtReportDetailsOpen(false)}
/>
)}
{position && clickPositionAddress && isCourtReportOpen && (
<CourtReport
address={clickPositionAddress}
position={position}
handleClose={() => {
setIsCourtReportOpen(false);
setPosition(null);
refetch();
}}
onReportSuccess={() => setMode(false)}
/>
)}
<div className="absolute bottom-10 right-6 z-10 flex flex-col items-end gap-y-3">
<Button
isIconOnly
aria-label="Current Location"
type="button"
className="justify-center rounded-full bg-primary shadow-md"
onClick={handleMoveUserLocation}
>
<MdMyLocation size={22} className="text-white" />
</Button>
<Button
startContent={<BiSolidLocationPlus size={20} />}
aria-label="Court Report"
type="button"
className="justify-center rounded-full bg-primary text-white shadow-md"
onClick={handleToggleMapClickEvent}
>
{mode ? '취소' : '농구장 제보'}
</Button>
</div>
</div>
<Modal size="sm" isOpen={isOpen} onClose={onClose} placement="center">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
농구장 제보
</ModalHeader>
<ModalBody>
<p>로그인한 사용자만 이용할 수 있는 서비스입니다.</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
닫기
</Button>
<Button color="primary" onPress={() => router.push('/login')}>
로그인하러 가기
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
import React, { useState } from 'react';
import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message';
import {
Textarea,
Input,
Select,
RadioGroup,
Radio,
SelectItem,
Button,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from '@nextui-org/react';
import { IoIosClose } from 'react-icons/io';
import { FaTrashCan } from 'react-icons/fa6';
import axiosInstance from '@/app/api/axiosInstance';
import {
basketballCourtType,
basketballCourtSize,
basketballConvenience,
} from '@/constants/courtReportData';
import { BasketballCourtReport } from '@/types/basketballCourt/basketballCourtReport';
import { CameraIcon } from './icons/CameraIcon';
interface CourtReportProps {
position: { lat: number; lng: number };
address: string;
handleClose: () => void;
onReportSuccess: () => void;
}
const CourtReport: React.FC<CourtReportProps> = ({
position,
address,
handleClose,
onReportSuccess,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [reportSuccess, setReportSuccess] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const {
control,
register,
handleSubmit,
formState: { errors },
} = useForm<BasketballCourtReport>();
const onSubmit: SubmitHandler<BasketballCourtReport> = async (data) => {
const formData = new FormData();
if (file) {
formData.append('image', file);
}
// nightLighting : 있음(LIGHT) , 없음(NON_LIGHT)
// openingHours : 24시(ALL_NIGHT), 제한(NON_ALL_NIGHT)
// fee : 무료(FREE) , 유료(NON_FREE)
// parkingAvailable : 가능(PARKING_AVAILABLE), 불가능(PARKING_UNAVAILABLE)
// 백엔드에 보낼 형식 맞추기
if (data.convenience?.length === 0) {
data.convenience = null;
}
if (data.indoorOutdoor === undefined) {
data.indoorOutdoor = null;
}
if (data.nightLighting === '있음') {
data.nightLighting = 'LIGHT';
} else if (data.nightLighting === '없음') {
data.nightLighting = 'NON_LIGHT';
} else {
data.nightLighting = null;
}
if (data.openingHours === '24시') {
data.openingHours = 'ALL_NIGHT';
} else if (data.openingHours === '제한') {
data.openingHours = 'NON_ALL_LIGHT';
} else {
data.openingHours = null;
}
if (data.fee === '무료') {
data.fee = 'FREE';
} else if (data.fee === '유료') {
data.fee = 'NON_FREE';
} else {
data.fee = null;
}
if (data.parkingAvailable === '가능') {
data.parkingAvailable = 'PARKING_AVAILABLE';
} else if (data.parkingAvailable === '불가능') {
data.parkingAvailable = 'PARKING_UNAVAILABLE';
} else {
data.parkingAvailable = null;
}
const finalData = {
...data,
address,
latitude: position.lat,
longitude: position.lng,
};
formData.append(
'data',
new Blob([JSON.stringify(finalData)], {
type: 'application/json',
})
);
try {
const response = await axiosInstance.post('/api/map/report', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.status === 200) {
setReportSuccess(true);
onOpen();
onReportSuccess();
}
} catch (error) {
setReportSuccess(false);
onOpen();
}
};
const MAX_FILE_SIZE_MB = 1;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files && e.target.files[0];
if (selectedFile) {
if (selectedFile.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
alert(`파일 크기는 ${MAX_FILE_SIZE_MB}MB를 초과할 수 없습니다.`);
e.target.value = '';
} else {
setFile(selectedFile);
const imageUrl = URL.createObjectURL(selectedFile);
setPreviewUrl(imageUrl);
}
}
};
const resetPreview = () => {
setFile(null);
setPreviewUrl(null);
};
const handleFileDelete = () => {
resetPreview();
};
return (
<div
className={`absolute inset-0 z-20 m-auto w-full max-w-md overflow-y-auto rounded-lg
bg-background text-sm shadow-md transition-all duration-300 ease-in-out
md:max-h-[90vh] md:text-lg lg:text-xl`}
>
<div className="relative h-full overflow-y-auto">
<div className="sticky top-0 z-30 flex h-14 w-full items-center justify-center border-b bg-background">
<p className="text-center text-xl font-semibold">농구장 제보하기</p>
<Button
isIconOnly
className="bg-gradient absolute right-2 top-2"
onClick={handleClose}
aria-label="Close"
>
<IoIosClose size={30} />
</Button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative flex h-48 w-full items-center justify-center bg-gray-200 dark:bg-gray-800">
<div className="absolute top-0 z-10 flex h-8 w-full items-center justify-between bg-yellow-200 px-4 font-semibold">
<p className="text-sm text-black sm:text-xs">
숨겨진 농구장을 제보해주시면 레벨 점수를 드립니다.
</p>
<Button
color="primary"
radius="full"
size="sm"
className="h-4 w-fit min-w-8 p-0 text-xs"
>
30점
</Button>
</div>
<div>
<Button
className="z-10 mt-2"
color="primary"
radius="full"
startContent={
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label htmlFor="fileInput">
<CameraIcon />
</label>
}
>
<label htmlFor="fileInput">
<span className="font-medium">
{file ? '사진 변경' : '사진 추가'}
</span>
</label>
</Button>
</div>
<input
id="fileInput"
type="file"
accept="image/png, image/jpg, image/jpeg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{previewUrl && (
<>
<Button
size="sm"
radius="full"
aria-label="삭제"
startContent={<FaTrashCan size={16} />}
className="absolute bottom-2 right-2 z-30 gap-1 bg-gray-400 p-1 font-bold text-white"
onClick={handleFileDelete}
>
삭제
</Button>
<img
src={previewUrl}
alt="미리보기"
className="absolute inset-0 h-full w-full object-cover"
/>
</>
)}
</div>
<div className="mt-2 flex flex-col p-4">
<Input
className="z-10 font-semibold"
radius="sm"
isRequired
labelPlacement="outside"
variant="bordered"
type="string"
label="농구장명"
placeholder="농구장명을 입력해 주세요."
{...register('courtName', {
required: true,
minLength: {
value: 2,
message: '농구장 이름을 2자 이상 30자 이하로 입력해 주세요.',
},
maxLength: {
value: 30,
message: '농구장 이름을 2자 이상 30자 이하로 입력해 주세요.',
},
})}
/>
<div className="flex h-6 items-center">
<ErrorMessage
errors={errors}
name="courtName"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<div className="w-full text-sm">
<p className="font-semibold">주소</p>
<p>{address}</p>
</div>
<div className="flex gap-4 pt-6 md:flex-nowrap">
<Select
className="z-10 max-w-xs font-semibold"
radius="sm"
labelPlacement="outside"
label="코트 종류"
placeholder="코트 종류"
variant="bordered"
{...register('courtType')}
>
{basketballCourtType.map((courtType) => (
<SelectItem key={courtType.value} value={courtType.value}>
{courtType.value}
</SelectItem>
))}
</Select>
<Select
className="z-10 max-w-xs font-semibold"
radius="sm"
labelPlacement="outside"
label="코트 사이즈"
placeholder="코트 사이즈"
variant="bordered"
{...register('courtSize')}
>
{basketballCourtSize.map((courtSize) => (
<SelectItem key={courtSize.value} value={courtSize.value}>
{courtSize.value}
</SelectItem>
))}
</Select>
</div>
<Input
className="z-10 pt-6 font-semibold"
radius="sm"
labelPlacement="outside"
variant="bordered"
type="number"
label="골대 수"
placeholder="농구장 골대 수를 입력해 주세요."
{...register('hoopCount', {
min: {
value: 1,
message: '농구장 골대 수를 1개 이상으로 입력해 주세요.',
},
max: {
value: 30,
message: '농구장 골대 수를 30개 이하로 입력해 주세요.',
},
})}
/>
<div className="flex h-6 items-center">
<ErrorMessage
errors={errors}
name="hoopCount"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<Select
className="z-10 font-semibold"
radius="sm"
labelPlacement="outside"
label="편의시설"
placeholder="편의시설"
selectionMode="multiple"
variant="bordered"
{...register('convenience')}
>
{basketballConvenience.map((convenience) => (
<SelectItem key={convenience.value} value={convenience.value}>
{convenience.value}
</SelectItem>
))}
</Select>
<Input
className="z-10 pt-6 font-semibold sm:pb-2"
radius="sm"
labelPlacement="outside"
variant="bordered"
type="tel"
label="전화번호"
placeholder="대표 전화번호를 입력해 주세요."
{...register('phoneNum', {
pattern: {
value: /^\d{2,3}-?\d{3,4}-?\d{4}$/,
message:
'전화번호 형식으로 입력해 주세요. 00-000-0000 또는 000-0000-0000',
},
})}
/>
<div className="flex h-6 items-center sm:pb-2">
<ErrorMessage
errors={errors}
name="phoneNum"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<Input
className="z-10 text-sm font-semibold"
radius="sm"
labelPlacement="outside"
variant="bordered"
type="url"
label="홈페이지"
placeholder="관련 홈페이지를 입력해 주세요."
{...register('website', {
pattern: {
value:
/(http[s]?|ftp):\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}/g,
message: '홈페이지 링크를 입력해 주세요.',
},
})}
/>
<div className="flex h-6 items-center">
<ErrorMessage
errors={errors}
name="website"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<div className="flex flex-col gap-4">
<Controller
control={control}
name="indoorOutdoor"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="실내외"
orientation="horizontal"
>
<Radio value="실내">실내</Radio>
<Radio value="야외">야외</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="nightLighting"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="야간 조명"
orientation="horizontal"
>
<Radio value="있음">있음</Radio>
<Radio value="없음">없음</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="openingHours"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="개방 시간"
orientation="horizontal"
>
<Radio value="제한">제한</Radio>
<Radio value="24시">24시</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="fee"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="사용료"
orientation="horizontal"
>
<Radio value="무료">무료</Radio>
<Radio value="유료">유료</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="parkingAvailable"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="주차 여부"
orientation="horizontal"
>
<Radio value="가능">가능</Radio>
<Radio value="불가능">불가능</Radio>
</RadioGroup>
)}
/>
</div>
<div className="mt-6 w-full">
<Textarea
radius="sm"
maxRows={3}
variant="bordered"
label="기타 정보"
placeholder="해당 농구장에 관한 기타 정보를 입력해 주세요."
{...register('additionalInfo', { maxLength: 300 })}
/>
</div>
</div>
<div className="sticky bottom-0 z-30 flex h-14 items-center border-t bg-background px-4">
<Button
radius="sm"
aria-label="제보하기"
type="submit"
className="text-md w-full bg-primary font-medium text-white"
>
제보하기
</Button>
</div>
</form>
</div>
<Modal isOpen={isOpen} onClose={onClose} placement="center">
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
농구장 제보
</ModalHeader>
<ModalBody>
{reportSuccess ? (
<>
<p>감사합니다! 농구장 제보가 완료되었습니다.</p>
<p>
관리자 검토 후 빠른 시일 내에 농구장 정보 반영하겠습니다.
</p>
</>
) : (
<p>농구장 제보에 실패했습니다. 잠시 후 다시 시도해주세요.</p>
)}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={handleClose}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
};
export default CourtReport;
제보시 주소, 농구장명만 필수값이고 나머지 정보들은 자유롭게 채울 수 있다.
✏️ 이미지 전송 - formData 활용
- 백엔드와 협의해 이미지 파일은 1MB를 넘지 않게 jpg, png, jpeg만 받기로 했다.
- 이미지를 서버에 보내기 위해 formData를 사용했다. (헤더에 'Content-Type': 'multipart/form-data'를 꼭 명시해줘야 한다.)
- 서버에 보내는 데이터가 일반 텍스트라면 그냥 전송할 수 있지만, 데이터가 객체라면 이진 데이터(Blob)으로 처리해 보내줘야 한다.
- new Blob으로 데이터를 처리할 때, 일반 객체 데이터를 담을 경우 이를 문자열화 해줘야 한다.
해당 데이터에 맞는 Next UI의 Input, Radio, Select, Textarea 요소들을 이용해 제보 컴포넌트를 구성하였고, react-hook-form을 이용해 유효성 검사 진행, 에러 메세지를 표시 했다.
✏️ react-hook-form 사용 이유
- 많은 useState를 사용하는 것보다 간단하게 코드 표현 가능
- 실시간 유효성 검사 및 동기화 가능
- 리렌더링 최소화
Next UI 에러 메세지 VS react-hook-form 에러 메세지
- 회원가입, 로그인 관련 화면의 Input에서는 Next UI isInvalid를 이용해 에러 메시지를 표시하고, 다른 Input에서는 react-hook-form ErrorMessage를 이용했다.
- 농구장 제보, 프로필 수정 등 필수값이 많지 않고, Input이 많아 작은 화면에서는 인풋 전체가 danger color로 표시되는 Next UI 보다 react-hook-form 에러 메세지를 이용해 표시하는 것이 낫다고 판단했기 때문이다.
- (에러 메세지는 항상 고정된 div 높이 안에 표시해 에러 메세지 존재 여부에 따라 UI가 변동되지 않도록 고정했다.)
✔️ 제보 상태 관리
제보 완료 후 해당 위치에 제보 검토 중인 마커가 표시되고 클릭시 자신이 제보한 정보를 확인할 수 있다.
관리자 검토 후 해당 농구장이 수락된다면 해당 유저는 30점의 점수를 받고, 제보 검토 중 마커는 실제 농구장 마커로 표시된다.
제보 후 바로 제보 상태 마커를 표시해주기 위해 react-query refetch를 사용했다.
/map/page.tsx
import React from 'react';
import KakaoMap from './components/KakaoMap';
const Map = () => <KakaoMap />;
export default Map;
KaKaoMap.tsx
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Map,
MapMarker,
MapTypeControl,
ZoomControl,
CustomOverlayMap,
MarkerClusterer,
} from 'react-kakao-maps-sdk';
import userLocationStore from '@/store/userLocationStore';
import { useQuery } from '@tanstack/react-query';
import getCourts from '@/services/basketballCourt/getCourts';
import { Button } from '@nextui-org/button';
import { MdMyLocation } from 'react-icons/md';
import { BiSolidLocationPlus } from 'react-icons/bi';
import { IoSearchSharp } from 'react-icons/io5';
import { BasketballCourts } from '@/types/basketballCourt/basketballCourts';
import { getAddressFromCoords } from '@/utils/getUserLocation';
import LocalStorage from '@/utils/localstorage';
import {
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
Tooltip,
} from '@nextui-org/react';
import getReportCourts from '@/services/basketballCourt/getReportCourts';
import CourtDetails from './CourtDetails';
import CourtReport from './CourtReport';
import CourtReportDetails from './CourtReportDetails';
export interface LatLng {
getLat: () => number;
getLng: () => number;
}
export interface MouseEventWithLatLng {
latLng: LatLng;
}
const KakaoMap = () => {
const [map, setMap] = useState<any>();
const userLocation = userLocationStore((state) => state.userLocation);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [location, setLocation] = useState({
center: {
// 지도의 초기 위치
lat: userLocation ? userLocation.latitude : 37.5737,
lng: userLocation ? userLocation.longitude : 127.0484,
},
});
const [isCourtReportOpen, setIsCourtReportOpen] = useState(false);
const [isCourtDetailsOpen, setIsCourtDetailsOpen] = useState<boolean>(false);
const [isCourtReportDetailsOpen, setIsCourtReportDetailsOpen] =
useState(false);
const [selectedCourtId, setSelectedCourtId] = useState<number>(1);
const [selectedCourtReportId, setSelectedCourtReportId] = useState<number>(1);
const [mode, setMode] = useState(false);
const [searchKeyword, setSearchKeyword] = useState<string>('');
const [coord, setCoord] = useState('');
const [position, setPosition] = useState<{
lat: number;
lng: number;
} | null>(null);
const [clickPositionAddress, setClickPositionAddress] = useState<string>('');
const { isOpen, onOpen, onClose } = useDisclosure();
const isLoggedIn = LocalStorage.getItem('isLoggedIn');
const router = useRouter();
const { error, data: courts } = useQuery<BasketballCourts[]>({
queryKey: ['courts'],
queryFn: getCourts,
});
const { data: reportCourts, refetch } = useQuery<BasketballCourts[]>({
queryKey: ['reportCourts'],
queryFn: getReportCourts,
});
if (error) {
console.log(error);
}
const handleToggleMapClickEvent = () => {
if (isLoggedIn === 'true') {
setMode((prev) => !prev);
} else {
onOpen();
}
if (mode === false) {
setPosition(null);
}
};
const handleMoveUserLocation = () => {
if (userLocation) {
const userPosition = new window.kakao.maps.LatLng(
userLocation.latitude,
userLocation.longitude
);
map.panTo(userPosition);
} else {
alert('위치 정보가 설정되지 않았습니다.');
}
};
const filterCourts = (
basketballCourts: BasketballCourts[],
keyword: string
) => {
const result = basketballCourts.filter(
(court) =>
court.courtName.includes(keyword) || court.address.includes(keyword)
);
return result;
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchKeyword(e.target.value);
};
const handleSearch = () => {
if (!map) return;
if (!searchKeyword) {
alert('검색어를 입력하세요.');
return;
}
const filteredCourts = filterCourts(courts || [], searchKeyword);
if (filteredCourts.length > 0) {
const firstCourt = filteredCourts[0];
const courtPosition = new window.kakao.maps.LatLng(
firstCourt.latitude,
firstCourt.longitude
);
map.panTo(courtPosition);
} else {
const ps = new window.kakao.maps.services.Places();
ps.keywordSearch(
searchKeyword,
(data: any, status: any) => {
if (status === window.kakao.maps.services.Status.OK) {
const firstResult = data[0];
if (firstResult) {
const { x, y } = firstResult;
const kakaoPosition = new window.kakao.maps.LatLng(y, x);
map.panTo(kakaoPosition);
}
} else if (status === window.kakao.maps.services.Status.ZERO_RESULT) {
alert('검색 결과가 존재하지 않습니다.');
} else if (status === window.kakao.maps.services.Status.ERROR) {
alert('검색 결과 중 오류가 발생했습니다.');
}
},
{ location: map.getCenter() }
);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleClickReport = (
_: kakao.maps.Map,
mouseEvent: MouseEventWithLatLng
) => {
const latlng = mouseEvent.latLng;
const lat = latlng.getLat();
const lng = latlng.getLng();
setCoord(
`클릭한 위치의 위도는 ${latlng.getLat()} 이고, 경도는 ${latlng.getLng()} 입니다`
);
console.log(coord);
if (mode === true) {
setPosition({
lat,
lng,
});
getAddressFromCoords(lat, lng)
.then(({ address, roadAddress }) => {
// 도로명 주소가 있으면 그 값을, 없으면 지번 주소를 사용
const finalAddress = roadAddress || address;
setClickPositionAddress(finalAddress);
})
.catch((error) => {
console.error('주소 정보를 가져오는데 실패했습니다.', error);
});
}
};
const handleClickReportMarker = () => {
setIsCourtReportOpen(true);
};
return (
<>
<div className="relative h-[calc(100vh-109px)] w-full">
<title>슬램톡 | 농구장 지도</title>
<Map
className="relative z-0"
id="map"
center={location.center}
level={3}
style={{ width: '100%', height: '100%' }}
onCreate={setMap}
onClick={handleClickReport}
>
<div className="absolute left-1/2 top-4 z-10 flex w-4/5 max-w-lg -translate-x-1/2 transform items-center justify-center rounded-md bg-background p-1 shadow-md">
<input
type="text"
placeholder="장소 검색"
className="flex-grow rounded-md border-0 p-2 focus:outline-none focus:ring-0"
value={searchKeyword}
onChange={handleSearchChange}
onKeyDown={handleKeyDown}
/>
<button
aria-label="Search"
type="button"
onClick={handleSearch}
className="ml-2 mr-2 flex h-full items-center justify-center rounded-md focus:outline-none"
>
<IoSearchSharp
size={20}
className="text-gray-400 hover:text-black"
/>
</button>
</div>
{mode && position && (
<>
<MapMarker
image={{
src: '/icons/marker-img.png',
size: {
width: 41,
height: 48,
},
}}
position={position}
clickable
onClick={handleClickReportMarker}
/>
<CustomOverlayMap
key={`overlay__${position.lat}-${position.lng}`}
position={position}
yAnchor={2.6}
xAnchor={0.67}
>
<div className="ml-12 flex items-center rounded border-1 border-primary bg-white px-2 py-1 text-sm font-medium text-black shadow-sm">
이 곳 제보하기
</div>
</CustomOverlayMap>
</>
)}
{reportCourts?.map((court) => (
<>
<MapMarker
key={court.courtId}
position={{ lat: court.latitude, lng: court.longitude }}
image={{
src: '/icons/marker-img.png',
size: {
width: 41,
height: 48,
},
}}
clickable
onClick={() => {
setIsCourtReportDetailsOpen(true);
setSelectedCourtReportId(court.courtId);
}}
/>
<CustomOverlayMap
key={`overlay__${court.latitude}-${court.longitude}`}
position={{ lat: court.latitude, lng: court.longitude }}
yAnchor={2.6}
xAnchor={0.66}
>
<Tooltip content={court.courtName} showArrow placement="right">
<div className="ml-12 flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-black shadow-sm">
#{court.courtId} 제보 검토중
</div>
</Tooltip>
</CustomOverlayMap>
</>
))}
<MarkerClusterer
averageCenter // 클러스터에 포함된 마커들의 평균 위치를 클러스터 마커 위치로 설정
minLevel={8} // 클러스터 할 최소 지도 레벨
>
{courts?.map((court) => (
<>
<MapMarker
key={court.courtId}
position={{ lat: court.latitude, lng: court.longitude }}
image={{
src: '/icons/marker-img.png',
size: {
width: 41,
height: 48,
},
}}
clickable
onClick={() => {
setIsCourtDetailsOpen(true);
setSelectedCourtId(court.courtId);
}}
/>
<CustomOverlayMap
key={`overlay__${court.latitude}-${court.longitude}`}
position={{ lat: court.latitude, lng: court.longitude }}
yAnchor={2.6}
xAnchor={0.63}
>
<div className="ml-12 flex items-center rounded border border-gray-300 bg-white px-2 py-1 text-sm font-medium text-black shadow-sm">
{court.courtName}
</div>
</CustomOverlayMap>
</>
))}
</MarkerClusterer>
<div className="mt-20 !bg-primary !text-primary">
<MapTypeControl position="BOTTOMLEFT" />
<ZoomControl position="RIGHT" />
</div>
</Map>
{isCourtDetailsOpen && (
<CourtDetails
courtId={selectedCourtId}
handleClose={() => setIsCourtDetailsOpen(false)}
/>
)}
{isCourtReportDetailsOpen && (
<CourtReportDetails
courtId={selectedCourtReportId}
onClose={() => setIsCourtReportDetailsOpen(false)}
/>
)}
{position && clickPositionAddress && isCourtReportOpen && (
<CourtReport
address={clickPositionAddress}
position={position}
handleClose={() => {
setIsCourtReportOpen(false);
setPosition(null);
refetch();
}}
onReportSuccess={() => setMode(false)}
/>
)}
<div className="absolute bottom-10 right-6 z-10 flex flex-col items-end gap-y-3">
<Button
isIconOnly
aria-label="Current Location"
type="button"
className="justify-center rounded-full bg-primary shadow-md"
onClick={handleMoveUserLocation}
>
<MdMyLocation size={22} className="text-white" />
</Button>
<Button
startContent={<BiSolidLocationPlus size={20} />}
aria-label="Court Report"
type="button"
className="justify-center rounded-full bg-primary text-white shadow-md"
onClick={handleToggleMapClickEvent}
>
{mode ? '취소' : '농구장 제보'}
</Button>
</div>
</div>
<Modal size="sm" isOpen={isOpen} onClose={onClose} placement="center">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
농구장 제보
</ModalHeader>
<ModalBody>
<p>로그인한 사용자만 이용할 수 있는 서비스입니다.</p>
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
닫기
</Button>
<Button color="primary" onPress={() => router.push('/login')}>
로그인하러 가기
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</>
);
};
export default KakaoMap;
type, react-query 관련
export interface BasketballCourts {
courtId: number;
courtName: string;
address: string;
latitude: number;
longitude: number;
}
export interface BasketballCourtsDetails {
courtId: number;
courtName: string;
address: string;
latitude: number;
longitude: number;
courtType: string;
indoorOutdoor: string;
courtSize: string;
hoopCount: number;
nightLighting: boolean;
openingHours: boolean;
fee: boolean;
parkingAvailable: boolean;
phoneNum: string | null;
website: string | null;
convenience: string[] | null;
additionalInfo: string | null;
photoUrl: string | null;
chatroomId: number;
}
export interface BasketballCourtReport {
file: File | null;
courtName: string;
address: string;
latitude: number;
longitude: number;
courtType: string | null;
indoorOutdoor: string | null;
courtSize: string | null;
hoopCount: number | null;
nightLighting: string | null; // default: 없음
openingHours: string | null; // 제한
fee: string | null; // 무료
parkingAvailable: string | null; // 불가능
phoneNum: string | null;
website: string | null;
convenience: string | null;
additionalInfo: string | null;
}
import axiosInstance from '@/app/api/axiosInstance';
import { BasketballCourts } from '@/types/basketballCourt/basketballCourts';
const getCourts = async () => {
try {
const response = await axiosInstance.get('/api/map/courts');
const courtsData = response.data.results;
const basketballCourts: BasketballCourts[] = courtsData.map(
(court: BasketballCourts) => ({
courtId: court.courtId,
address: court.address,
courtName: court.courtName,
latitude: court.latitude,
longitude: court.longitude,
})
);
return basketballCourts;
} catch (error) {
console.log(error);
throw error;
}
};
export default getCourts;
import axiosInstance from '@/app/api/axiosInstance';
import { BasketballCourtsDetails } from '@/types/basketballCourt/basketballCourtsDetails';
const getCourtDetails = async (courtId: number) => {
try {
const response = await axiosInstance.get(`/api/map/courts/${courtId}`);
if (response.status === 200) {
const court = response.data.results;
const courtDetails: BasketballCourtsDetails = {
courtId: court.courtId,
courtName: court.courtName,
address: court.address,
latitude: court.latitude,
longitude: court.longitude,
courtType: court.courtType,
indoorOutdoor: court.indoorOutdoor,
courtSize: court.courtSize,
hoopCount: court.hoopCount,
nightLighting: court.nightLighting,
openingHours: court.openingHours,
fee: court.fee,
parkingAvailable: court.parkingAvailable,
phoneNum: court.phoneNum,
website: court.website,
convenience: court.convenience,
additionalInfo: court.additionalInfo,
photoUrl: court.photoUrl,
chatroomId: court.chatroomId,
};
return courtDetails;
}
} catch (error) {
console.log(error);
throw error;
}
return null;
};
export default getCourtDetails;
import axiosInstance from '@/app/api/axiosInstance';
import { BasketballCourts } from '@/types/basketballCourt/basketballCourts';
const getReportCourts = async () => {
try {
const response = await axiosInstance.get('/api/map/report/courts');
const courtsData = response.data.results;
const basketballCourts: BasketballCourts[] = courtsData.map(
(court: BasketballCourts) => ({
courtId: court.courtId,
address: court.address,
courtName: court.courtName,
latitude: court.latitude,
longitude: court.longitude,
})
);
return basketballCourts;
} catch (error) {
console.log(error);
throw error;
}
};
export default getReportCourts;
import axiosInstance from '@/app/api/axiosInstance';
import { BasketballCourtsDetails } from '@/types/basketballCourt/basketballCourtsDetails';
const getReportCourtDetails = async (courtId: number) => {
try {
const response = await axiosInstance.get(
`/api/map/report/courts/${courtId}`
);
if (response.status === 200) {
const court = response.data.results;
const courtDetails: BasketballCourtsDetails = {
courtId: court.courtId,
courtName: court.courtName,
address: court.address,
latitude: court.latitude,
longitude: court.longitude,
courtType: court.courtType,
indoorOutdoor: court.indoorOutdoor,
courtSize: court.courtSize,
hoopCount: court.hoopCount,
nightLighting: court.nightLighting,
openingHours: court.openingHours,
fee: court.fee,
parkingAvailable: court.parkingAvailable,
phoneNum: court.phoneNum,
website: court.website,
convenience: court.convenience,
additionalInfo: court.additionalInfo,
photoUrl: court.photoUrl,
chatroomId: court.chatroomId,
};
return courtDetails;
}
} catch (error) {
console.log(error);
throw error;
}
return null;
};
export default getReportCourtDetails;
위치, 주소 정보 관련
export interface Coords {
latitude: number;
longitude: number;
}
import { create } from 'zustand';
interface LocationState {
userLocation: { latitude: number; longitude: number } | null;
userAddress: string;
setUserLocation: (location: { latitude: number; longitude: number }) => void;
setUserAddress: (address: string) => void;
}
const userLocationStore = create<LocationState>((set) => ({
userLocation: null,
userAddress: '',
setUserLocation: (location: { latitude: number; longitude: number }) =>
set({ userLocation: location }),
setUserAddress: (address: string) => set({ userAddress: address }),
}));
export default userLocationStore;
import { Coords } from '@/types/location/userLocationType';
interface AddressResult {
address: {
address_name: string; // 지번 주소
};
road_address?: {
address_name: string | null; // 도로명 주소
} | null;
}
export const getUserLocation = (): Promise<Coords> =>
new Promise((resolve, reject) => {
if (!('geolocation' in navigator)) {
reject(new Error('이 브라우저는 위치 서비스를 지원하지 않습니다.'));
} else {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
resolve({ latitude, longitude });
},
(error) => {
console.log(error);
reject(error);
}
);
}
});
export const getAddressFromCoords = (
latitude: number,
longitude: number
): Promise<{ address: string; roadAddress: string | null }> =>
new Promise((resolve, reject) => {
window.kakao.maps.load(() => {
// API가 로드될 때까지 기다림
const geocoder = new window.kakao.maps.services.Geocoder();
const callback = (result: AddressResult[], status: string) => {
if (status === window.kakao.maps.services.Status.OK) {
const address = result[0].address.address_name;
const roadAddress = result[0].road_address
? result[0].road_address.address_name
: null;
resolve({ address, roadAddress });
} else {
reject(new Error('주소를 가져오는데 실패했습니다.'));
}
};
geocoder.coord2Address(longitude, latitude, callback);
});
});
제보 관련
import React, { useState } from 'react';
import { useForm, SubmitHandler, Controller } from 'react-hook-form';
import { ErrorMessage } from '@hookform/error-message';
import {
Textarea,
Input,
Select,
RadioGroup,
Radio,
SelectItem,
Button,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from '@nextui-org/react';
import { IoIosClose } from 'react-icons/io';
import { FaTrashCan } from 'react-icons/fa6';
import axiosInstance from '@/app/api/axiosInstance';
import {
basketballCourtType,
basketballCourtSize,
basketballConvenience,
} from '@/constants/courtReportData';
import { BasketballCourtReport } from '@/types/basketballCourt/basketballCourtReport';
import { CameraIcon } from './icons/CameraIcon';
interface CourtReportProps {
position: { lat: number; lng: number };
address: string;
handleClose: () => void;
onReportSuccess: () => void;
}
const CourtReport: React.FC<CourtReportProps> = ({
position,
address,
handleClose,
onReportSuccess,
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const [reportSuccess, setReportSuccess] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const {
control,
register,
handleSubmit,
formState: { errors },
} = useForm<BasketballCourtReport>();
const onSubmit: SubmitHandler<BasketballCourtReport> = async (data) => {
const formData = new FormData();
if (file) {
formData.append('image', file);
}
// nightLighting : 있음(LIGHT) , 없음(NON_LIGHT)
// openingHours : 24시(ALL_NIGHT), 제한(NON_ALL_NIGHT)
// fee : 무료(FREE) , 유료(NON_FREE)
// parkingAvailable : 가능(PARKING_AVAILABLE), 불가능(PARKING_UNAVAILABLE)
// 백엔드에 보낼 형식 맞추기
if (data.convenience?.length === 0) {
data.convenience = null;
}
if (data.indoorOutdoor === undefined) {
data.indoorOutdoor = null;
}
if (data.nightLighting === '있음') {
data.nightLighting = 'LIGHT';
} else if (data.nightLighting === '없음') {
data.nightLighting = 'NON_LIGHT';
} else {
data.nightLighting = null;
}
if (data.openingHours === '24시') {
data.openingHours = 'ALL_NIGHT';
} else if (data.openingHours === '제한') {
data.openingHours = 'NON_ALL_LIGHT';
} else {
data.openingHours = null;
}
if (data.fee === '무료') {
data.fee = 'FREE';
} else if (data.fee === '유료') {
data.fee = 'NON_FREE';
} else {
data.fee = null;
}
if (data.parkingAvailable === '가능') {
data.parkingAvailable = 'PARKING_AVAILABLE';
} else if (data.parkingAvailable === '불가능') {
data.parkingAvailable = 'PARKING_UNAVAILABLE';
} else {
data.parkingAvailable = null;
}
const finalData = {
...data,
address,
latitude: position.lat,
longitude: position.lng,
};
formData.append(
'data',
new Blob([JSON.stringify(finalData)], {
type: 'application/json',
})
);
try {
const response = await axiosInstance.post('/api/map/report', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
if (response.status === 200) {
setReportSuccess(true);
onOpen();
onReportSuccess();
}
} catch (error) {
setReportSuccess(false);
onOpen();
}
};
const MAX_FILE_SIZE_MB = 1;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files && e.target.files[0];
if (selectedFile) {
if (selectedFile.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
alert(`파일 크기는 ${MAX_FILE_SIZE_MB}MB를 초과할 수 없습니다.`);
e.target.value = '';
} else {
setFile(selectedFile);
const imageUrl = URL.createObjectURL(selectedFile);
setPreviewUrl(imageUrl);
}
}
};
const resetPreview = () => {
setFile(null);
setPreviewUrl(null);
};
const handleFileDelete = () => {
resetPreview();
};
return (
<div
className={`absolute inset-0 z-20 m-auto w-full max-w-md overflow-y-auto rounded-lg
bg-background text-sm shadow-md transition-all duration-300 ease-in-out
md:max-h-[90vh] md:text-lg lg:text-xl`}
>
<div className="relative h-full overflow-y-auto">
<div className="sticky top-0 z-30 flex h-14 w-full items-center justify-center border-b bg-background">
<p className="text-center text-xl font-semibold">농구장 제보하기</p>
<Button
size="sm"
radius="full"
variant="light"
isIconOnly
className="absolute right-2 top-2"
onClick={handleClose}
aria-label="Close"
>
<IoIosClose size={30} />
</Button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="relative flex h-48 w-full items-center justify-center bg-gray-200 dark:bg-gray-800">
<div className="absolute top-0 z-10 flex h-8 w-full items-center justify-between bg-yellow-200 px-4 font-semibold">
<p className="text-sm text-black sm:text-xs">
숨겨진 농구장을 제보해주시면 레벨 점수를 드립니다.
</p>
<Button
color="primary"
radius="full"
size="sm"
className="h-4 w-fit min-w-8 p-0 text-xs"
>
30점
</Button>
</div>
<div>
<Button
className="z-10 mt-2"
color="primary"
radius="full"
startContent={
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label htmlFor="fileInput">
<CameraIcon />
</label>
}
>
<label htmlFor="fileInput">
<span className="font-medium">
{file ? '사진 변경' : '사진 추가'}
</span>
</label>
</Button>
</div>
<input
id="fileInput"
type="file"
accept="image/png, image/jpg, image/jpeg"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{previewUrl && (
<>
<Button
size="sm"
radius="full"
aria-label="삭제"
startContent={<FaTrashCan size={16} />}
className="absolute bottom-2 right-2 z-30 gap-1 bg-gray-400 p-1 font-bold text-white"
onClick={handleFileDelete}
>
삭제
</Button>
<img
src={previewUrl}
alt="미리보기"
className="absolute inset-0 h-full w-full object-cover"
/>
</>
)}
</div>
<div className="mt-2 flex flex-col p-4">
<Input
className="z-10 font-semibold"
radius="sm"
isRequired
labelPlacement="outside"
variant="bordered"
type="string"
label="농구장명"
placeholder="농구장명을 입력해 주세요."
{...register('courtName', {
required: true,
minLength: {
value: 2,
message: '농구장 이름을 2자 이상 30자 이하로 입력해 주세요.',
},
maxLength: {
value: 30,
message: '농구장 이름을 2자 이상 30자 이하로 입력해 주세요.',
},
})}
/>
<div className="flex h-6 items-center">
<ErrorMessage
errors={errors}
name="courtName"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<div className="w-full text-sm">
<p className="font-semibold">주소</p>
<p>{address}</p>
</div>
<div className="flex gap-4 pt-6 md:flex-nowrap">
<Select
className="z-10 max-w-xs font-semibold"
radius="sm"
labelPlacement="outside"
label="코트 종류"
placeholder="코트 종류"
variant="bordered"
{...register('courtType')}
>
{basketballCourtType.map((courtType) => (
<SelectItem key={courtType.value} value={courtType.value}>
{courtType.value}
</SelectItem>
))}
</Select>
<Select
className="z-10 max-w-xs font-semibold"
radius="sm"
labelPlacement="outside"
label="코트 사이즈"
placeholder="코트 사이즈"
variant="bordered"
{...register('courtSize')}
>
{basketballCourtSize.map((courtSize) => (
<SelectItem key={courtSize.value} value={courtSize.value}>
{courtSize.value}
</SelectItem>
))}
</Select>
</div>
<Input
className="z-10 pt-6 font-semibold"
radius="sm"
labelPlacement="outside"
variant="bordered"
type="number"
label="골대 수"
placeholder="농구장 골대 수를 입력해 주세요."
{...register('hoopCount', {
min: {
value: 1,
message: '농구장 골대 수를 1개 이상으로 입력해 주세요.',
},
max: {
value: 30,
message: '농구장 골대 수를 30개 이하로 입력해 주세요.',
},
})}
/>
<div className="flex h-6 items-center">
<ErrorMessage
errors={errors}
name="hoopCount"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<Select
className="z-10 font-semibold"
radius="sm"
labelPlacement="outside"
label="편의시설"
placeholder="편의시설"
selectionMode="multiple"
variant="bordered"
{...register('convenience')}
>
{basketballConvenience.map((convenience) => (
<SelectItem key={convenience.value} value={convenience.value}>
{convenience.value}
</SelectItem>
))}
</Select>
<Input
className="z-10 pt-6 font-semibold sm:pb-2"
radius="sm"
labelPlacement="outside"
variant="bordered"
type="tel"
label="전화번호"
placeholder="대표 전화번호를 입력해 주세요."
{...register('phoneNum', {
pattern: {
value: /^\d{2,3}-?\d{3,4}-?\d{4}$/,
message:
'전화번호 형식으로 입력해 주세요. 00-000-0000 또는 000-0000-0000',
},
})}
/>
<div className="flex h-6 items-center sm:pb-2">
<ErrorMessage
errors={errors}
name="phoneNum"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<Input
className="z-10 text-sm font-semibold"
radius="sm"
labelPlacement="outside"
variant="bordered"
type="url"
label="홈페이지"
placeholder="관련 홈페이지를 입력해 주세요."
{...register('website', {
pattern: {
value:
/(http[s]?|ftp):\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}/g,
message: '홈페이지 링크를 입력해 주세요.',
},
})}
/>
<div className="flex h-6 items-center">
<ErrorMessage
errors={errors}
name="website"
render={({ message }) => (
<p className="text-xs text-danger">{message}</p>
)}
/>
</div>
<div className="flex flex-col gap-4">
<Controller
control={control}
name="indoorOutdoor"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="실내외"
orientation="horizontal"
>
<Radio value="실내">실내</Radio>
<Radio value="야외">야외</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="nightLighting"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="야간 조명"
orientation="horizontal"
>
<Radio value="있음">있음</Radio>
<Radio value="없음">없음</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="openingHours"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="개방 시간"
orientation="horizontal"
>
<Radio value="제한">제한</Radio>
<Radio value="24시">24시</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="fee"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="사용료"
orientation="horizontal"
>
<Radio value="무료">무료</Radio>
<Radio value="유료">유료</Radio>
</RadioGroup>
)}
/>
<Controller
control={control}
name="parkingAvailable"
render={({ field: { onChange, value } }) => (
<RadioGroup
value={value || ''}
onChange={(value) => onChange(value)}
className="text-sm font-semibold"
label="주차 여부"
orientation="horizontal"
>
<Radio value="가능">가능</Radio>
<Radio value="불가능">불가능</Radio>
</RadioGroup>
)}
/>
</div>
<div className="mt-6 w-full">
<Textarea
radius="sm"
maxRows={3}
variant="bordered"
label="기타 정보"
placeholder="해당 농구장에 관한 기타 정보를 입력해 주세요."
{...register('additionalInfo', { maxLength: 300 })}
/>
</div>
</div>
<div className="sticky bottom-0 z-30 flex h-14 items-center border-t bg-background px-4">
<Button
radius="sm"
aria-label="제보하기"
type="submit"
className="text-md w-full bg-primary font-medium text-white"
>
제보하기
</Button>
</div>
</form>
</div>
<Modal isOpen={isOpen} onClose={onClose} placement="center">
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
농구장 제보
</ModalHeader>
<ModalBody>
{reportSuccess ? (
<>
<p>감사합니다! 농구장 제보가 완료되었습니다.</p>
<p>
관리자 검토 후 빠른 시일 내에 농구장 정보 반영하겠습니다.
</p>
</>
) : (
<p>농구장 제보에 실패했습니다. 잠시 후 다시 시도해주세요.</p>
)}
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={handleClose}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
</div>
);
};
export default CourtReport;
상세보기
'use client';
import React, { useState } from 'react';
import {
Button,
Modal,
ModalContent,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
} from '@nextui-org/react';
import { IoIosClose } from 'react-icons/io';
import { FaPhoneAlt, FaParking, FaTag, FaRegDotCircle } from 'react-icons/fa';
import Image from 'next/image';
import { FaLocationDot, FaClock, FaLightbulb } from 'react-icons/fa6';
import { PiChatsCircle } from 'react-icons/pi';
import { useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import getCourtDetails from '@/services/basketballCourt/getCourtDetails';
import { RiShareBoxFill } from 'react-icons/ri';
import Link from 'next/link';
import LocalStorage from '@/utils/localstorage';
import { CourtIcon } from './icons/CourtIcon';
import { HoopIcon } from './icons/HoopIcon';
import { FeeIcon } from './icons/FeeIcon';
import { InfoIcon } from './icons/InfoIcon';
import { WebsiteIcon } from './icons/WebsiteIcon';
interface CourtDetailsProps {
courtId: number;
handleClose: () => void;
}
const CourtDetails: React.FC<CourtDetailsProps> = ({
courtId,
handleClose,
}) => {
const router = useRouter();
const isLoggedIn = LocalStorage.getItem('isLoggedIn');
const [loginMsg, setLoginMsg] = useState('');
const [alertMsg, setAlertMsg] = useState('');
const [isTel, setIsTel] = useState(false);
const { isOpen, onOpen, onClose } = useDisclosure();
const { error, data: selectedPlace } = useQuery({
queryKey: ['courtDetails', courtId],
queryFn: () => getCourtDetails(courtId),
});
if (error) {
console.log(error);
router.push('/map');
}
if (selectedPlace) {
const handlePhoneClick = () => {
if (selectedPlace.phoneNum) {
setAlertMsg(
`이 전화번호로 연결하시겠습니까? ${selectedPlace.phoneNum}`
);
setIsTel(true);
onOpen();
}
};
const handleCopyAddress = async () => {
if (selectedPlace.address) {
try {
await navigator.clipboard.writeText(selectedPlace.address);
setAlertMsg('주소가 복사되었습니다.');
onOpen();
} catch (copyError) {
console.error('주소 복사 중 오류 발생:', copyError);
setAlertMsg('주소를 복사하는 데 실패했습니다.');
onOpen();
}
}
};
const handleShareCourt = () => {
const shareUrl = `https://www.slam-talk.site/map/${courtId}`;
navigator.clipboard
.writeText(shareUrl)
.then(() => {
setAlertMsg(
'해당 농구장 정보를 공유할 수 있는 링크가 복사되었습니다.'
);
onOpen();
})
.catch((err) => {
setAlertMsg('링크 복사에 실패했습니다.');
onOpen();
console.error('링크 복사 실패:', err);
});
};
const handleGoChatting = () => {
if (isLoggedIn === 'true') {
router.push(`/chatting/chatroom/${selectedPlace.chatroomId}`);
} else {
setLoginMsg('로그인 후 이용할 수 있는 서비스입니다.');
onOpen();
}
};
const handleCloseLoginModal = () => {
setLoginMsg('');
onClose();
};
const handleCloseAlert = () => {
setAlertMsg('');
onClose();
};
return (
<>
<title>슬램톡 | 농구장 지도</title>
<div
className={`min-w-md sm-h-full absolute inset-0 z-40 m-auto h-fit max-h-[calc(100vh-109px)] w-fit min-w-96 max-w-md overflow-y-auto rounded-lg
bg-background shadow-md transition-all duration-300 ease-in-out sm:min-w-full`}
>
<div className="w-full text-sm">
<div className="relative h-56 w-full sm:h-52">
<Image
fill
alt="농구장 사진"
src={
selectedPlace.photoUrl
? selectedPlace.photoUrl
: '/images/basketball-court.svg'
}
/>
<Button
size="sm"
radius="full"
variant="light"
isIconOnly
className="absolute right-2 top-2"
onClick={handleClose}
aria-label="Close"
>
<IoIosClose size={30} />
</Button>
</div>
<div className="p-4">
<div className="flex justify-between gap-4">
<h2 className="text-xl font-bold">{selectedPlace.courtName}</h2>
<div>
<Button
color="primary"
radius="full"
size="md"
startContent={<PiChatsCircle />}
aria-label="시설 채팅 바로가기"
onClick={handleGoChatting}
>
시설 채팅
</Button>
</div>
</div>
<span className="break-keep rounded-sm bg-gray-100 px-1 text-gray-500 dark:bg-gray-300 dark:text-gray-600">
{selectedPlace.indoorOutdoor}
</span>
<div className="my-2 flex w-full items-center justify-start gap-3">
<Button
size="sm"
aria-label="공유하기"
variant="bordered"
className="border-0 p-0"
radius="full"
startContent={<RiShareBoxFill />}
onClick={handleShareCourt}
>
공유하기
</Button>
<hr className="h-4 w-px bg-gray-300" />
<Button
size="sm"
variant="bordered"
startContent={<FaRegDotCircle />}
radius="full"
className="border-0 p-0"
onClick={() => {
window.open(
`https://map.kakao.com/link/to/${selectedPlace.courtName},${selectedPlace.latitude},${selectedPlace.longitude}`,
'_blank'
);
}}
>
길찾기
</Button>
</div>
<div className="flex justify-center " />
<hr className="w-90 my-4 h-px bg-gray-300" />
<div className="my-4 flex flex-col gap-4">
<div className="flex gap-2 align-middle">
<FaLocationDot
size={16}
className="dark:text-gray-20 text-gray-400"
/>
<span>{selectedPlace.address}</span>
<button type="button" onClick={handleCopyAddress}>
<span className="text-blue-500">복사</span>
</button>
</div>
<div className="flex gap-2 align-middle">
<FaClock
size={14}
className="text-gray-400 dark:text-gray-200"
/>
<span>
개방 시간:{' '}
<span className="text-rose-400">
{selectedPlace.openingHours}
</span>
</span>
</div>
<div className="flex gap-2 align-middle">
<FaPhoneAlt
size={15}
className="pointer-events-auto text-gray-400 dark:text-gray-200"
onClick={handlePhoneClick}
/>
<span>
{selectedPlace.phoneNum ? selectedPlace.phoneNum : '-'}
</span>
</div>
<div className="flex gap-2 align-middle">
<FeeIcon className="text-gray-400 dark:text-gray-200" />
<span className="text-info text-blue-500">
이용료: {selectedPlace.fee}
</span>
</div>
<div className="flex gap-2 align-middle">
<WebsiteIcon className="text-gray-400 dark:text-gray-200" />
<span className="text-blue-500">
{selectedPlace.website ? (
<Link href={selectedPlace.website} target="_blank">
{selectedPlace.website}
</Link>
) : (
'-'
)}
</span>
</div>
<div className="flex gap-2 align-middle">
<CourtIcon className="text-gray-400 dark:text-gray-200" />
<span className="font-medium">
코트 종류: {selectedPlace.courtType}
</span>
</div>
<div className="flex gap-2 align-middle">
<CourtIcon className="text-gray-400 dark:text-gray-200" />
<span className="font-medium">
코트 사이즈: {selectedPlace.courtSize}
</span>
</div>
<div className="flex gap-2 align-middle">
<HoopIcon className="text-gray-400 dark:text-gray-200" />
<span className="font-medium">
골대 수: {selectedPlace.hoopCount}
</span>
</div>
<div className="flex gap-2 align-middle">
<FaLightbulb
size={17}
className="text-gray-400 dark:text-gray-200"
/>
<span>야간 조명: {selectedPlace.nightLighting}</span>
</div>
<div className="flex gap-2 align-middle">
<FaParking
size={17}
className="text-gray-400 dark:text-gray-200"
/>
<span>주차: {selectedPlace.parkingAvailable}</span>
</div>
<div className="flex gap-2 align-middle text-sm">
<FaTag
size={17}
className="text-gray-400 dark:text-gray-200"
/>
<ul className="flex gap-2">
{selectedPlace.convenience &&
selectedPlace.convenience.map(
(tag: string, idx: number) =>
tag !== '' ? (
<li
// eslint-disable-next-line react/no-array-index-key
key={idx}
className="rounded-sm bg-gray-100 px-1 text-gray-500 dark:bg-gray-300 dark:text-gray-600"
>
<span>{tag}</span>
</li>
) : (
'-'
)
)}
</ul>
</div>
<div className="flex gap-2 align-middle">
<InfoIcon className="text-gray-400 dark:text-gray-200" />
<span className="text-sm">
{selectedPlace.additionalInfo
? selectedPlace.additionalInfo
: '-'}
</span>
</div>
</div>
</div>
</div>
</div>
{loginMsg && (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseLoginModal}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
농구장 시설 채팅
</ModalHeader>
<ModalBody>
<p>{loginMsg}</p>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="light"
onPress={handleCloseLoginModal}
>
닫기
</Button>
<Button
color="primary"
onPress={() => router.push('/login')}
>
로그인하러 가기
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)}
{alertMsg && (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseAlert}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
알림
</ModalHeader>
<ModalBody>
<p>{alertMsg}</p>
</ModalBody>
<ModalFooter>
<Button color="primary" onPress={handleCloseAlert}>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)}
{isTel && (
<Modal
size="sm"
isOpen={isOpen}
onClose={handleCloseAlert}
placement="center"
>
<ModalContent>
{() => (
<>
<ModalHeader className="flex flex-col gap-1">
전화 연결
</ModalHeader>
<ModalBody>
<p>{alertMsg}</p>
</ModalBody>
<ModalFooter>
<Button
color="danger"
variant="light"
onPress={handleCloseLoginModal}
>
취소
</Button>
<Button
color="primary"
onClick={() => {
window.location.href = `tel:${selectedPlace.phoneNum}`;
}}
>
확인
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
)}
</>
);
}
return null;
};
export default CourtDetails;
농구장 상세 보기, 공유용 상세보기, 제보 상세보기는 거의 동일하고 조금씩 달라서 생략했다.
제보하기 기능을 구현할게 아니라면 KakaoMap.tsx 파일만 참고하면 된다.
프론트엔드 팀원 중 react-query를 사용해본 사람이 없었는데 프로젝트 중반 캐싱과 refetch 기능이 필요해졌다. 멘토님도 react-query를 추천하시고 다른 팀에 react-query를 잘 쓰시는 분이 있다길래 가서 직접 궁금한거 질문하고 간단 설명을 요청드려서 팀원 전부 듣고 바로 적용시킬 수 있었다. 백지였으면 처음에 빠르게 도입하기 힘들었을텐데 용기내서 질문하길 잘했다.
react-hook-form도 사용해보고 싶다고 생각했는데 제보 모달을 구현하면서 상태가 너무 많아져서 도입하게 되었다. 직접 써보니 ErrorMessage, Controller 등 편하게 사용할 수 있었다. 전체 기능을 모두 익히긴 오래걸리지만 필요한 부분만 빠르게 배워 사용하는 것도 좋은 방법이라고 느꼈다.
지도 부분을 맡은 백엔드 분과는 협업이 원활해 감사했다. 농구장 정보를 한 번에 안불러오고 간략 정보, 상세 정보를 꼭 분리해야 해나 해서 프론트엔드 멘토링때 여쭤봤는데 데이터가 많아질 수록 간략 정보만 먼저 불러와서 표시하는게 빠를거라고 했다. 백엔드분이 처음부터 API를 잘 나눠주셨던거다.
농구장 제보 모달에서 둘 중에 하나인 값(예_야외 / 실내)을 Radio로 표현한 부분이 있는데 실제로는 String이고, Null이 될 수 있다. 중간에 구현하다 보니 Type이 총 3개가 되서 백엔드에서는 Boolean으로 표시하는 이 데이터 값을 String으로 바꿔줄 수 있냐고 요청드렸다. 다행히 프로젝트 완전 막바지는 아니라 바꿀 수 있었다. 이런 점은 초반에 미리 고려해야 겠다.
회의 기록, 멘토링 기록, 에러 기록 등 기록을 잘하자!
PR 스크린샷 첨부, 자세히 작성하면 발전 과정이 기록될 수 있다.
커밋은 기능별로 쪼개서 -> 리뷰어가 힘들다. 매번 브랜치를 새로 만드는게 적응이 안됐을 때도 있었는데 계속 하다보니 적당하게 쪼개는게 제일 작업하기 편하다.
공식 문서를 애용하자.
내가 원하는 요구사항을 명확히 말하자.
앱 전체에서 통일성을 주려면 프론트끼리 스타일 맞추는 것도 중요하다.
카카오 지도 구현에 대한 질문이 있으면 남겨주세요. 제가 도움이 될 수 있으면 답변 남기겠습니다.
계속 리팩토링, 유지보수 중인데 들어오셔서 집근처 농구장 제보도 하고 의견 남겨주시면 감사합니다 🙂
⚠️ 5시간 넘게 시간을 들여 작성한 Velog 글이 출간을 누르니 출간되지도 않고 이전 글로 저장되어 그동안 작성한 글이 모두 날라가 다시 작성했다. Velog 보기에도 좋고 검색도 잘되서 사용하려고 했는데 많이 실망이다. 여기에 글을 쓰려면 항상 마크다운으로 다른 곳에 작성하고 복사 붙여넣기만 해야겠다..
와 kakao map api 참고자료가 별로 없었는데 감사합니다!!