diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 3a2046832..587537f12 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -34,11 +34,180 @@ npm run chromatic # Chromatic으로 시각적 테스트 배포 npm run generate:sitemap # sitemap.xml 생성 ``` +## 아키텍처 개요 + +### 기술 스택 + +- React 19 + TypeScript +- Vite 번들러 (webpack 설정도 있으나 Vite가 주력) +- styled-components 스타일링 +- TanStack React Query v5 서버 상태 관리 +- Zustand 클라이언트 상태 관리 +- React Router v7 +- date-fns 날짜 처리 +- Framer Motion 애니메이션 +- Swiper 캐러셀 +- react-datepicker 날짜 선택 +- react-markdown 마크다운 렌더링 + +### 외부 서비스 통합 + +- **Mixpanel**: 사용자 분석 및 이벤트 트래킹 +- **Sentry**: 에러 모니터링 및 성능 추적 +- **Channel.io**: 고객 지원 채팅 +- **Kakao SDK**: 카카오 공유 기능 +- **Naver Map**: 동아리방 위치 지도 (네이버 클라우드 플랫폼) + +모든 SDK는 `src/utils/initSDK.ts`에서 초기화되며, 각각 환경 변수 필요. + +### 환경 변수 + +`.env` 파일에 다음 환경 변수 설정 필요 (모두 `VITE_` 접두사 사용): + +- `VITE_API_URL` - 백엔드 API URL +- `VITE_MIXPANEL_TOKEN` - Mixpanel 프로젝트 토큰 +- `VITE_SENTRY_DSN` - Sentry DSN +- `VITE_SENTRY_RELEASE` - Sentry 릴리즈 버전 +- `VITE_ENABLE_SENTRY_IN_DEV` - 개발 환경에서 Sentry 활성화 여부 (true/false) +- `VITE_CHANNEL_PLUGIN_KEY` - Channel.io 플러그인 키 +- `VITE_KAKAO_JAVASCRIPT_KEY` - Kakao JavaScript 키 +- `VITE_NAVER_MAP_CLIENT_ID` - 네이버 지도 API 클라이언트 ID + +### 프로젝트 구조 + +**경로 별칭**: `@/*`는 `src/*`로 매핑 + +**주요 디렉토리**: + +- `src/apis/` - 도메인별 API 함수 (club, auth, application, applicants) +- `src/hooks/Queries/` - API를 래핑하는 React Query 훅 (useClub, useApplication, useApplicants) +- `src/store/` - Zustand 스토어 (useCategoryStore, useSearchStore) +- `src/pages/` - 라우트 기반 페이지 컴포넌트 +- `src/components/` - 공용 UI 컴포넌트 +- `src/context/` - React Context 프로바이더 (AdminClubContext - SSE 상태 관리) +- `src/experiments/` - A/B 테스트 실험 정의 및 관리 +- `src/mocks/` - MSW(Mock Service Worker) 핸들러 +- `src/utils/` - 유틸리티 함수 (날짜 파싱, 유효성 검사, 디바운스, WebView 브릿지 등) +- `src/errors/` - 커스텀 에러 클래스 +- `src/types/` - 공용 타입 정의 +- `src/constants/` - 상수 관리 (queryKeys, storageKeys, status, eventName, api, snsConfig 등) + +### API 레이어 패턴 + +API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 패턴을 따름: + +- `handleResponse()` - 응답 파싱, `{ data: {...} }` 형식 자동 언래핑 +- `secureFetch()` - 인증된 요청, 403 시 토큰 자동 갱신 + +쿼리 키는 `src/constants/queryKeys.ts`에 중앙 관리. + +### 인증 플로우 + +- JWT는 localStorage에 저장 (`accessToken` 키, `src/constants/storageKeys.ts`에서 관리) +- 리프레시 토큰은 쿠키로 처리 +- `src/apis/auth/secureFetch.ts`의 `secureFetch()`가 자동 토큰 갱신 담당 +- 어드민 라우트는 `PrivateRoute` 컴포넌트로 보호 + +### 실험(A/B 테스트) 프레임워크 + +`src/experiments/`에서 Mixpanel 기반 실험 관리: + +- `definitions.ts` - 실험 정의 (key, variants, weights) +- `ExperimentRepository.ts` - 실험 할당 및 변형 조회 로직 +- `initializeExperiments.ts` - 앱 시작 시 실험 초기화 +- `useExperiment()` 훅으로 컴포넌트에서 실험 변형 사용 + +**예시**: + +```typescript +const { variant } = useExperiment(mainBannerExperiment); +// variant는 'A' 또는 'B' +``` + +### MSW (Mock Service Worker) + +`src/mocks/`에서 API 모킹 관리: + +- `handlers/` - 도메인별 모킹 핸들러 +- `browser.ts` - MSW 브라우저 워커 설정 +- Storybook 및 개발 환경에서 사용 + +### 주요 유틸리티 함수 + +`src/utils/`에 공용 유틸리티 함수 모음: + +- `formatRelativeDateTime.ts` - 상대적 시간 표시 ("2시간 전") +- `recruitmentDateParser.ts` - 모집 기간 파싱 +- `debounce.ts` - 디바운스 함수 +- `validateSocialLink.ts` - SNS 링크 유효성 검사 +- `isInAppWebView.ts` - 인앱 WebView 감지 +- `webviewBridge.ts` - 네이티브 앱과 통신 +- `initSDK.ts` - 외부 SDK 초기화 (Mixpanel, Sentry, Channel.io, Kakao) + +### 반응형 브레이크포인트 + +`src/styles/mediaQuery.ts`에 정의: + +- mini_mobile: 375px +- mobile: 500px +- tablet: 700px +- laptop: 1280px +- Desktop: 1280px 초과 (기본값) + +### 테마 시스템 + +테마는 `src/styles/theme/`에 colors, typography, transitions로 정의. styled-components `ThemeProvider`를 통해 접근. + +### 상수 관리 + +`src/constants/`에 모든 상수 중앙 관리: + +- `queryKeys.ts` - React Query 쿼리 키 (도메인.액션 형식) +- `storageKeys.ts` - localStorage 키 (`accessToken`, `hasConsentedPersonalInfo`) +- `status.ts` - 지원 상태 정의 (PENDING, APPROVED, REJECTED 등) +- `eventName.ts` - Mixpanel 이벤트명 +- `api.ts` - API 엔드포인트 URL +- `snsConfig.ts` - SNS 플랫폼 설정 +- `applicationForm.ts` - 지원서 폼 설정 +- `uploadLimit.ts` - 파일 업로드 제한 + +### 실시간 업데이트 + +지원자 상태 업데이트를 위해 SSE(Server-Sent Events) 사용, `AdminClubContext`에서 관리. + +### 날짜 처리 + +- `date-fns` 라이브러리 사용 (Moment.js 대신) +- `formatRelativeDateTime` 유틸로 상대 시간 표시 +- `react-datepicker` 컴포넌트로 날짜 입력 + +### 애니메이션 + +- `framer-motion` 라이브러리로 페이지 전환, 모달, 제스처 등 애니메이션 구현 +- `src/styles/theme/transitions.ts`에 공통 트랜지션 정의 + +### 캐러셀 + +- `swiper` 라이브러리로 이미지 슬라이더, 카드 캐러셀 구현 + +## 테스트 + +- Jest + React Testing Library +- MSW로 API 모킹 +- 테스트 파일은 `*.test.ts` 또는 `*.test.tsx` 형식 +- 커버리지 리포트: `npm run coverage` + +## Storybook + +- 컴포넌트 독립 개발 환경 +- MSW addon으로 API 모킹 지원 +- Chromatic으로 시각적 회귀 테스트 + ## Claude Code Agent `.claude/agents/` 디렉토리에 전담 agent 정의: -- `api-hooks-agent.md` - React Query 훅 생성 및 관리 전담 +- `API훅부서.md` - React Query 훅 생성 및 관리 전담 Agent 사용 시 해당 문서를 참조하여 일관된 패턴 유지. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9129e7128..ecb36c4c0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import { ScrollToTop } from '@/hooks/Scroll/ScrollToTop'; import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab'; import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute'; import ClubDetailPage from '@/pages/ClubDetailPage/ClubDetailPage'; -import ClubMapPage from '@/pages/ClubMapPage/ClubMapPage'; import MainPage from '@/pages/MainPage/MainPage'; import GlobalStyles from '@/styles/Global.styles'; import { theme } from '@/styles/theme'; @@ -79,14 +78,6 @@ const App = () => { } /> - - - - } - /> {/*한국어핸들 */} { } /> - - - - } - /> {/*새로 빌드해서 배포할 앱 주소 url*/} { } /> - - - - } - /> { } /> - - - - } - /> + + + diff --git a/frontend/src/components/map/InteractiveMapView/InteractiveMapView.styles.ts b/frontend/src/components/map/InteractiveMapView/InteractiveMapView.styles.ts new file mode 100644 index 000000000..f6a3cf9d6 --- /dev/null +++ b/frontend/src/components/map/InteractiveMapView/InteractiveMapView.styles.ts @@ -0,0 +1,26 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; + +export const Container = styled.div` + position: relative; + width: 100%; + height: 100%; +`; + +export const MapArea = styled.div` + width: 100%; + height: 100%; + overflow: hidden; +`; + +export const InfoCardWrapper = styled.div` + position: absolute; + bottom: 50px; + left: 50%; + transform: translateX(-50%); + z-index: 10; + + ${media.tablet} { + bottom: 30px; + } +`; diff --git a/frontend/src/components/map/InteractiveMapView/InteractiveMapView.tsx b/frontend/src/components/map/InteractiveMapView/InteractiveMapView.tsx new file mode 100644 index 000000000..118a14256 --- /dev/null +++ b/frontend/src/components/map/InteractiveMapView/InteractiveMapView.tsx @@ -0,0 +1,63 @@ +import { RefObject, useCallback, useRef } from 'react'; +import MapClubInfoCard from '@/components/map/MapClubInfoCard/MapClubInfoCard'; +import { ClubLocation } from '@/constants/clubLocation'; +import { NaverMapInstance } from '@/hooks/Map/useMapZoom'; +import { useNaverMap } from '@/hooks/Map/useNaverMap'; +import * as Styled from './InteractiveMapView.styles'; + +interface InteractiveMapViewProps { + location: ClubLocation; + clubName: string; + clubLogo?: string; + active: boolean; + markerSize?: number; + bubbleFontSize?: number; + bubbleFontWeight?: number; + mapInstanceRef: RefObject; +} + +const InteractiveMapView = ({ + location, + clubName, + clubLogo, + active, + markerSize = 40, + bubbleFontSize = 13, + bubbleFontWeight = 700, + mapInstanceRef, +}: InteractiveMapViewProps) => { + const mapRef = useRef(null); + + useNaverMap(mapRef, location.lat, location.lng, { + active, + interactive: true, + markerSize, + bubbleText: '동아리방', + bubbleFontSize, + bubbleFontWeight, + mapInstanceRef, + }); + + const handleRecenter = useCallback(() => { + const map = mapInstanceRef.current; + if (map && window.naver) { + map.setCenter(new window.naver.maps.LatLng(location.lat, location.lng)); + } + }, [mapInstanceRef, location.lat, location.lng]); + + return ( + + + + + + + ); +}; + +export default InteractiveMapView; diff --git a/frontend/src/pages/ClubMapPage/ClubMapPage.styles.ts b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.styles.ts similarity index 52% rename from frontend/src/pages/ClubMapPage/ClubMapPage.styles.ts rename to frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.styles.ts index 236479112..8bbc74823 100644 --- a/frontend/src/pages/ClubMapPage/ClubMapPage.styles.ts +++ b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.styles.ts @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; import { colors } from '@/styles/theme/colors'; import { typography } from '@/styles/theme/typography'; @@ -7,49 +8,25 @@ const setTypography = (typo: { size: string; weight: number }) => ` font-weight: ${typo.weight}; `; -export const Container = styled.div` - position: relative; - width: 100%; - height: 100dvh; - overflow: hidden; -`; - -export const MapWrapper = styled.div` - width: 100%; - height: 100%; -`; - -export const BackButton = styled.button` - position: fixed; - top: calc(12px + var(--rn-safe-top, 0px)); - left: 16px; - z-index: 10; - width: 36px; - height: 36px; - padding: 0; - border: none; - background-color: ${colors.base.white}; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -`; - -export const BottomCard = styled.div` - position: fixed; - bottom: calc(55px + var(--rn-safe-bottom, 0px)); - left: 20px; - right: 20px; - z-index: 10; +export const Card = styled.div` + width: 357px; background-color: ${colors.base.white}; border-radius: 16px; - padding: 24px 16px; + padding: 24px 24px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); display: flex; align-items: center; gap: 12px; + box-sizing: border-box; + + ${media.tablet} { + width: 335px; + padding: 24px 16px; + } + + ${media.mobile} { + width: calc(100vw - 40px); + } `; export const ClubLogo = styled.img` @@ -70,11 +47,17 @@ export const ClubInfo = styled.div` `; export const ClubName = styled.span` - ${setTypography(typography.title.title5)}; + ${setTypography(typography.title.title2)}; color: ${colors.base.black}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + cursor: default; + user-select: none; + + ${media.tablet} { + ${setTypography(typography.title.title5)}; + } `; export const LocationRow = styled.div` @@ -90,19 +73,15 @@ export const LocationRow = styled.div` `; export const LocationText = styled.span` - ${setTypography(typography.paragraph.p6)}; + ${setTypography(typography.paragraph.p3)}; color: ${colors.gray[600]}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -`; + cursor: default; + user-select: none; -export const StatusMessage = styled.div` - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - ${setTypography(typography.paragraph.p4)}; - color: ${colors.gray[700]}; - text-align: center; + ${media.tablet} { + ${setTypography(typography.paragraph.p6)}; + } `; diff --git a/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.tsx b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.tsx new file mode 100644 index 000000000..ff9648058 --- /dev/null +++ b/frontend/src/components/map/MapClubInfoCard/MapClubInfoCard.tsx @@ -0,0 +1,34 @@ +import locationIcon from '@/assets/images/icons/location_icon.svg'; +import DefaultLogo from '@/assets/images/logos/default_profile_image.svg'; +import * as Styled from './MapClubInfoCard.styles'; + +interface MapClubInfoCardProps { + logo?: string; + name: string; + building: string; + detailLocation: string; +} + +const MapClubInfoCard = ({ + logo, + name, + building, + detailLocation, +}: MapClubInfoCardProps) => { + return ( + + + + {name} + + 위치 아이콘 + + {building} {detailLocation} + + + + + ); +}; + +export default MapClubInfoCard; diff --git a/frontend/src/components/map/MapModal/MapModal.styles.ts b/frontend/src/components/map/MapModal/MapModal.styles.ts new file mode 100644 index 000000000..cf7feafe9 --- /dev/null +++ b/frontend/src/components/map/MapModal/MapModal.styles.ts @@ -0,0 +1,57 @@ +import styled from 'styled-components'; +import { media } from '@/styles/mediaQuery'; +import { colors } from '@/styles/theme/colors'; + +const CONTROL_Z_INDEX = 10; + +export const Container = styled.div` + position: relative; + width: 86vw; + max-width: 1100px; + height: 73vh; + max-height: 820px; + border-radius: 20px; + overflow: hidden; + background-color: ${colors.base.white}; + margin-bottom: 40px; + + ${media.tablet} { + width: 100vw; + height: 100dvh; + max-width: none; + max-height: none; + border-radius: 0; + margin-bottom: 0; + } +`; + +export const ActionButton = styled.button` + position: absolute; + top: 16px; + right: 16px; + z-index: ${CONTROL_Z_INDEX}; + width: 36px; + height: 36px; + padding: 0; + border: none; + background-color: ${colors.base.white}; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + ${media.tablet} { + top: calc(12px + var(--rn-safe-top, 0px)); + left: 16px; + right: auto; + } +`; + +export const ZoomControlsWrapper = styled.div` + position: absolute; + bottom: 50px; + right: 40px; + z-index: ${CONTROL_Z_INDEX}; +`; diff --git a/frontend/src/components/map/MapModal/MapModal.tsx b/frontend/src/components/map/MapModal/MapModal.tsx new file mode 100644 index 000000000..1a8214887 --- /dev/null +++ b/frontend/src/components/map/MapModal/MapModal.tsx @@ -0,0 +1,67 @@ +import { useRef } from 'react'; +import CloseButtonIcon from '@/assets/images/icons/close_button_icon.svg?react'; +import PrevButtonIcon from '@/assets/images/icons/prev_button_icon.svg?react'; +import PortalModal from '@/components/common/Modal/PortalModal'; +import InteractiveMapView from '@/components/map/InteractiveMapView/InteractiveMapView'; +import MapZoomControls from '@/components/map/MapZoomControls/MapZoomControls'; +import { ClubLocation } from '@/constants/clubLocation'; +import { NaverMapInstance, useMapZoom } from '@/hooks/Map/useMapZoom'; +import useDevice from '@/hooks/useDevice'; +import * as Styled from './MapModal.styles'; + +interface MapModalProps { + isOpen: boolean; + onClose: () => void; + clubName: string; + clubLogo?: string; + location: ClubLocation; +} + +const MapModal = ({ + isOpen, + onClose, + clubName, + clubLogo, + location, +}: MapModalProps) => { + const { isMobile, isTablet } = useDevice(); + const isMobileView = isMobile || isTablet; + const mapInstanceRef = useRef(null); + const { zoomIn, zoomOut } = useMapZoom(mapInstanceRef); + + return ( + + + + + + {isMobileView ? ( + + ) : ( + + )} + + + {!isMobileView && ( + + + + )} + + + ); +}; + +export default MapModal; diff --git a/frontend/src/components/map/MapZoomControls/MapZoomControls.styles.ts b/frontend/src/components/map/MapZoomControls/MapZoomControls.styles.ts new file mode 100644 index 000000000..367a2b829 --- /dev/null +++ b/frontend/src/components/map/MapZoomControls/MapZoomControls.styles.ts @@ -0,0 +1,78 @@ +import styled from 'styled-components'; +import { colors } from '@/styles/theme/colors'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + background-color: ${colors.base.white}; + border-radius: 16px; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); + width: 40px; +`; + +export const Button = styled.button` + width: 40px; + height: 40px; + padding: 10px; + border: none; + background-color: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + background-color: ${colors.gray[100]}; + } + + &:first-child { + border-radius: 16px 16px 0 0; + } + + &:last-child { + border-radius: 0 0 16px 16px; + } +`; + +export const Divider = styled.div` + width: 40px; + height: 1px; + background-color: ${colors.gray[300]}; +`; + +export const PlusIcon = styled.span` + position: relative; + width: 18px; + height: 18px; + + &::before, + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + background-color: ${colors.gray[600]}; + border-radius: 1px; + } + + &::before { + width: 18px; + height: 2.5px; + transform: translate(-50%, -50%); + } + + &::after { + width: 2.5px; + height: 18px; + transform: translate(-50%, -50%); + } +`; + +export const MinusIcon = styled.span` + position: relative; + width: 18px; + height: 2.5px; + background-color: ${colors.gray[600]}; + border-radius: 1px; +`; diff --git a/frontend/src/components/map/MapZoomControls/MapZoomControls.tsx b/frontend/src/components/map/MapZoomControls/MapZoomControls.tsx new file mode 100644 index 000000000..3143d3abc --- /dev/null +++ b/frontend/src/components/map/MapZoomControls/MapZoomControls.tsx @@ -0,0 +1,22 @@ +import * as Styled from './MapZoomControls.styles'; + +interface MapZoomControlsProps { + onZoomIn: () => void; + onZoomOut: () => void; +} + +const MapZoomControls = ({ onZoomIn, onZoomOut }: MapZoomControlsProps) => { + return ( + + + + + + + + + + ); +}; + +export default MapZoomControls; diff --git a/frontend/src/components/map/NaverMap.tsx b/frontend/src/components/map/NaverMap.tsx deleted file mode 100644 index 8784c0a1d..000000000 --- a/frontend/src/components/map/NaverMap.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useRef } from 'react'; -import { useNaverMap } from '@/components/map/useNaverMap'; -import * as Styled from './NaverMap.styles'; - -interface NaverMapProps { - lat: number; - lng: number; - clubName: string; - building: string; - detailLocation: string; -} - -const NaverMap = ({ lat, lng }: NaverMapProps) => { - const mapRef = useRef(null); - - useNaverMap(mapRef, lat, lng, { interactive: false }); - - return ; -}; - -export default NaverMap; diff --git a/frontend/src/components/map/NaverMap.styles.ts b/frontend/src/components/map/NaverMap/NaverMap.styles.ts similarity index 75% rename from frontend/src/components/map/NaverMap.styles.ts rename to frontend/src/components/map/NaverMap/NaverMap.styles.ts index 4a413f99f..8401e4695 100644 --- a/frontend/src/components/map/NaverMap.styles.ts +++ b/frontend/src/components/map/NaverMap/NaverMap.styles.ts @@ -5,4 +5,8 @@ export const MapContainer = styled.div` height: 100%; overflow: hidden; + + * { + cursor: default !important; + } `; diff --git a/frontend/src/components/map/NaverMap/NaverMap.tsx b/frontend/src/components/map/NaverMap/NaverMap.tsx new file mode 100644 index 000000000..78f67f094 --- /dev/null +++ b/frontend/src/components/map/NaverMap/NaverMap.tsx @@ -0,0 +1,18 @@ +import { useRef } from 'react'; +import { ClubLocation } from '@/constants/clubLocation'; +import { useNaverMap } from '@/hooks/Map/useNaverMap'; +import * as Styled from './NaverMap.styles'; + +interface NaverMapProps { + location: Pick; +} + +const NaverMap = ({ location }: NaverMapProps) => { + const mapRef = useRef(null); + + useNaverMap(mapRef, location.lat, location.lng, { interactive: false }); + + return ; +}; + +export default NaverMap; diff --git a/frontend/src/components/map/useNaverMap.ts b/frontend/src/components/map/useNaverMap.ts deleted file mode 100644 index b014b5173..000000000 --- a/frontend/src/components/map/useNaverMap.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { useEffect } from 'react'; -import markerIcon from '@/assets/images/icons/marker.svg'; -import { loadNaverMapScript } from './loadNaverMapScript'; - -interface UseNaverMapOptions { - bubbleText?: string; - interactive?: boolean; -} - -export const useNaverMap = ( - mapRef: React.RefObject, - lat: number, - lng: number, - options?: UseNaverMapOptions, -) => { - useEffect(() => { - let mapInstance: { destroy: () => void } | null = null; - - loadNaverMapScript().then(() => { - if (!mapRef.current || !window.naver) return; - - const { naver } = window; - - const position = new naver.maps.LatLng(lat, lng); - - const interactive = options?.interactive ?? true; - - mapInstance = new naver.maps.Map(mapRef.current, { - center: position, - zoom: 17, - logoControl: false, - mapDataControl: false, - scaleControl: false, - draggable: interactive, - scrollWheel: interactive, - keyboardShortcuts: interactive, - disableDoubleClickZoom: !interactive, - pinchZoom: interactive, - }); - - const markerContent = options?.bubbleText - ? ` -
-
-
${options.bubbleText}
-
-
- -
- ` - : ``; - - new naver.maps.Marker({ - position, - map: mapInstance, - icon: { - content: markerContent, - anchor: new naver.maps.Point(20, 40), - }, - }); - }); - - return () => { - mapInstance?.destroy(); - }; - }, [mapRef, lat, lng, options?.interactive, options?.bubbleText]); -}; diff --git a/frontend/src/hooks/Map/useMapZoom.ts b/frontend/src/hooks/Map/useMapZoom.ts new file mode 100644 index 000000000..d387add25 --- /dev/null +++ b/frontend/src/hooks/Map/useMapZoom.ts @@ -0,0 +1,24 @@ +import { RefObject, useCallback } from 'react'; + +export interface NaverMapInstance { + getZoom: () => number; + setZoom: (zoom: number) => void; + setCenter: (latlng: unknown) => void; + destroy: () => void; +} + +export const useMapZoom = ( + mapInstanceRef: RefObject, +) => { + const zoomIn = useCallback(() => { + const map = mapInstanceRef.current; + if (map) map.setZoom(map.getZoom() + 1); + }, [mapInstanceRef]); + + const zoomOut = useCallback(() => { + const map = mapInstanceRef.current; + if (map) map.setZoom(map.getZoom() - 1); + }, [mapInstanceRef]); + + return { zoomIn, zoomOut }; +}; diff --git a/frontend/src/hooks/Map/useNaverMap.ts b/frontend/src/hooks/Map/useNaverMap.ts new file mode 100644 index 000000000..c3e2ae44a --- /dev/null +++ b/frontend/src/hooks/Map/useNaverMap.ts @@ -0,0 +1,138 @@ +import { RefObject, useEffect } from 'react'; +import markerIcon from '@/assets/images/icons/marker.svg'; +import { colors } from '@/styles/theme/colors'; +import { loadNaverMapScript } from '@/utils/loadNaverMapScript'; +import { NaverMapInstance } from './useMapZoom'; + +interface UseNaverMapOptions { + active?: boolean; + interactive?: boolean; + markerSize?: number; + bubbleText?: string; + bubbleFontSize?: number; + bubbleFontWeight?: number; + mapInstanceRef?: RefObject; +} + +const buildMarkerContent = ( + markerSize: number, + bubbleText?: string, + bubbleFontSize = 13, + bubbleFontWeight = 700, +): string => { + const image = ``; + + if (!bubbleText) return image; + + return ` +
+
+
${bubbleText}
+
+
+ ${image} +
+ `; +}; + +export const useNaverMap = ( + mapRef: RefObject, + lat: number, + lng: number, + options?: UseNaverMapOptions, +) => { + const { + active = true, + interactive = true, + markerSize = 40, + bubbleText, + bubbleFontSize, + bubbleFontWeight, + mapInstanceRef: externalRef, + } = options ?? {}; + + useEffect(() => { + if (!active) return; + + let mapInstance: NaverMapInstance | null = null; + + loadNaverMapScript().then(() => { + if (!mapRef.current || !window.naver) return; + + const { naver } = window; + const position = new naver.maps.LatLng(lat, lng); + + mapInstance = new naver.maps.Map(mapRef.current, { + center: position, + zoom: 17, + logoControl: false, + mapDataControl: false, + scaleControl: false, + draggable: interactive, + scrollWheel: interactive, + keyboardShortcuts: interactive, + disableDoubleClickZoom: !interactive, + pinchZoom: interactive, + }); + + if (externalRef) { + externalRef.current = mapInstance; + } + + new naver.maps.Marker({ + position, + map: mapInstance, + icon: { + content: buildMarkerContent( + markerSize, + bubbleText, + bubbleFontSize, + bubbleFontWeight, + ), + anchor: new naver.maps.Point(markerSize / 2, markerSize), + }, + }); + }); + + return () => { + mapInstance?.destroy(); + if (externalRef) externalRef.current = null; + }; + }, [ + mapRef, + lat, + lng, + active, + interactive, + markerSize, + bubbleText, + bubbleFontSize, + bubbleFontWeight, + externalRef, + ]); +}; diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts b/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts index e8f393168..bf0431b4c 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.styles.ts @@ -38,8 +38,10 @@ export const LeftSection = styled.div` flex-direction: column; gap: 12px; flex-shrink: 0; + width: 400px; ${media.tablet} { + width: 100%; gap: 0px; } `; @@ -61,8 +63,13 @@ export const MapCard = styled.div` border-radius: 20px; border: 1px solid ${colors.gray[400]}; overflow: hidden; + cursor: pointer; background-color: #f2f2f2; + + * { + cursor: pointer !important; + } `; export const MapDetailText = styled.div` @@ -74,6 +81,8 @@ export const MapDetailText = styled.div` font-size: 14px; color: ${colors.gray[700]}; + cursor: default; + user-select: none; img { width: 12px; diff --git a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx index bcc01bf34..052b4903b 100644 --- a/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx +++ b/frontend/src/pages/ClubDetailPage/ClubDetailPage.tsx @@ -1,14 +1,11 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { - useParams, - useLocation as useRouterLocation, - useSearchParams, -} from 'react-router-dom'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; import locationIcon from '@/assets/images/icons/location_icon.svg'; import Footer from '@/components/common/Footer/Footer'; import Header from '@/components/common/Header/Header'; import UnderlineTabs from '@/components/common/UnderlineTabs/UnderlineTabs'; -import NaverMap from '@/components/map/NaverMap'; +import MapModal from '@/components/map/MapModal/MapModal'; +import NaverMap from '@/components/map/NaverMap/NaverMap'; import { clubLocations } from '@/constants/clubLocation'; import { PAGE_VIEW, USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; @@ -135,11 +132,11 @@ const ClubDetailPage = () => { [setSearchParams, trackEvent], ); - const routerLocation = useRouterLocation(); const clubLocation = clubLocations.find( (loc) => loc.clubName === clubDetail?.name, ); - const mapPath = clubLocation ? `${routerLocation.pathname}/map` : undefined; + + const [isMapModalOpen, setIsMapModalOpen] = useState(false); if (error) { return
동아리 정보를 불러오는데 실패했습니다.
; @@ -176,18 +173,12 @@ const ClubDetailPage = () => { socialLinks={clubDetail.socialLinks} introDescription={clubDetail.description.introDescription} location={clubLocation} - mapPath={mapPath} + onMapClick={() => setIsMapModalOpen(true)} /> {clubLocation && ( - - + setIsMapModalOpen(true)}> + @@ -238,6 +229,15 @@ const ClubDetailPage = () => { + {clubLocation && ( + setIsMapModalOpen(false)} + clubName={clubDetail.name} + clubLogo={clubDetail.logo} + location={clubLocation} + /> + )} {!isInAppWebView() &&