diff --git a/apps/web/app/_utils/device/device.ts b/apps/web/app/_utils/device/device.ts new file mode 100644 index 00000000..4416a577 --- /dev/null +++ b/apps/web/app/_utils/device/device.ts @@ -0,0 +1,16 @@ +/** + * 모바일 디바이스 감지 정규식 + */ +const MOBILE_REGEX = /iPhone|iPad|iPod|Android/i +const IOS_REGEX = /iPhone|iPad|iPod/i + +/** + * 현재 디바이스가 모바일인지 확인 + */ +export const isMobileDevice = (): boolean => + MOBILE_REGEX.test(navigator.userAgent) + +/** + * 현재 디바이스가 iOS인지 확인 + */ +export const isIOSDevice = (): boolean => IOS_REGEX.test(navigator.userAgent) diff --git a/apps/web/app/_utils/device/index.ts b/apps/web/app/_utils/device/index.ts new file mode 100644 index 00000000..f6ef6e0c --- /dev/null +++ b/apps/web/app/_utils/device/index.ts @@ -0,0 +1 @@ +export { isMobileDevice, isIOSDevice } from './device' diff --git a/apps/web/app/_utils/openDeepLink/index.ts b/apps/web/app/_utils/openDeepLink/index.ts new file mode 100644 index 00000000..44a9818e --- /dev/null +++ b/apps/web/app/_utils/openDeepLink/index.ts @@ -0,0 +1 @@ +export { openDeepLink } from './openDeepLink' diff --git a/apps/web/app/_utils/openDeepLink/openDeepLink.ts b/apps/web/app/_utils/openDeepLink/openDeepLink.ts new file mode 100644 index 00000000..5a5a76ac --- /dev/null +++ b/apps/web/app/_utils/openDeepLink/openDeepLink.ts @@ -0,0 +1,43 @@ +interface OpenDeepLinkParams { + appScheme: string + fallbackUrl: string + timeout?: number +} + +const DEEPLINK_TIMEOUT = 2500 + +/** + * 딥링크 실행 유틸 함수 + * - 앱이 설치되어 있으면 앱 실행 + * - 앱이 없으면 timeout 이후 fallbackUrl로 이동 + * - 앱 실행 시 페이지가 백그라운드로 가면 fallback 취소 + */ +export const openDeepLink = ({ + appScheme, + fallbackUrl, + timeout = DEEPLINK_TIMEOUT, +}: OpenDeepLinkParams): void => { + const startTime = Date.now() + + // 페이지가 백그라운드로 가면 앱이 실행된 것으로 간주 + const handleVisibilityChange = () => { + if (document.hidden) { + // 앱이 실행되어 페이지가 숨겨짐 + clearTimeout(fallbackTimeout) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + } + + document.addEventListener('visibilitychange', handleVisibilityChange) + + // 앱이 설치되지 않은 경우를 대비한 타임아웃 + const fallbackTimeout = setTimeout(() => { + document.removeEventListener('visibilitychange', handleVisibilityChange) + // 페이지가 포그라운드 상태로 유지되었다면 앱이 없는 것으로 간주 + if (!document.hidden && Date.now() - startTime >= timeout) { + window.location.href = fallbackUrl + } + }, timeout) + + window.location.href = appScheme +} diff --git a/apps/web/app/_utils/openKakaoTaxi/index.ts b/apps/web/app/_utils/openKakaoTaxi/index.ts new file mode 100644 index 00000000..6c644141 --- /dev/null +++ b/apps/web/app/_utils/openKakaoTaxi/index.ts @@ -0,0 +1 @@ +export { openKakaoTaxi } from './openKakaoTaxi' diff --git a/apps/web/app/_utils/openKakaoTaxi/openKakaoTaxi.ts b/apps/web/app/_utils/openKakaoTaxi/openKakaoTaxi.ts new file mode 100644 index 00000000..0bf786f8 --- /dev/null +++ b/apps/web/app/_utils/openKakaoTaxi/openKakaoTaxi.ts @@ -0,0 +1,35 @@ +import { addToast } from '@heroui/react' +import { openDeepLink } from '../openDeepLink' +import { isMobileDevice, isIOSDevice } from '../device' +import { Coord } from '@/map/_utils/toLatLng' + +interface OpenKakaoTaxiParams extends Coord { + placeName?: string +} + +/** + * 카카오택시 앱으로 특정 목적지를 설정하는 딥링크 함수 + * - 모바일: 카카오택시 앱이 설치되어 있으면 앱 실행, 없으면 앱스토어로 이동 + * - 데스크톱: 카카오택시 앱은 모바일 전용이므로 토스트 메시지로 안내 + */ +export const openKakaoTaxi = ({ + latitude, + longitude, + placeName = '목적지', +}: OpenKakaoTaxiParams): void => { + if (isMobileDevice()) { + const urls = buildKakaoTaxiUrls({ latitude, longitude }, placeName) + openDeepLink({ appScheme: urls.app, fallbackUrl: urls.store }) + } else { + addToast({ + title: '카카오택시 앱은 모바일에서만 이용 가능합니다.', + }) + } +} + +const buildKakaoTaxiUrls = (coords: Coord, placeName: string) => ({ + app: `kakaot://taxi?dest_lat=${coords.latitude}&dest_lng=${coords.longitude}&end_name=${encodeURIComponent(placeName)}`, + store: isIOSDevice() + ? 'https://apps.apple.com/kr/app/kakaotaxi/id981110422' + : 'https://play.google.com/store/apps/details?id=com.kakao.taxi', +}) diff --git a/apps/web/app/_utils/openNaverMap/index.ts b/apps/web/app/_utils/openNaverMap/index.ts new file mode 100644 index 00000000..1c7e4e73 --- /dev/null +++ b/apps/web/app/_utils/openNaverMap/index.ts @@ -0,0 +1 @@ +export { openNaverMap } from './openNaverMap' diff --git a/apps/web/app/_utils/openNaverMap/openNaverMap.ts b/apps/web/app/_utils/openNaverMap/openNaverMap.ts new file mode 100644 index 00000000..5602ec08 --- /dev/null +++ b/apps/web/app/_utils/openNaverMap/openNaverMap.ts @@ -0,0 +1,32 @@ +import { openDeepLink } from '../openDeepLink' +import { isMobileDevice } from '../device' +import { Coord } from '@/map/_utils/toLatLng' + +interface OpenNaverMapParams extends Coord { + placeName?: string +} + +/** + * 네이버 지도 앱으로 특정 위치를 여는 딥링크 함수 + * - 모바일: 네이버 지도 앱이 설치되어 있으면 앱 실행, 없으면 웹으로 이동 + * - 데스크톱: 네이버 지도 웹 페이지로 이동 + */ +export const openNaverMap = ({ + latitude, + longitude, + placeName = '공주대학교', +}: OpenNaverMapParams): void => { + const urls = buildNaverMapUrls({ latitude, longitude }, placeName) + + if (isMobileDevice()) { + openDeepLink({ appScheme: urls.app, fallbackUrl: urls.web }) + } else { + // 데스크톱: 웹 페이지로 바로 이동 + window.open(urls.web, '_blank') + } +} + +const buildNaverMapUrls = (coords: Coord, placeName: string) => ({ + app: `nmap://place?lat=${coords.latitude}&lng=${coords.longitude}&name=${encodeURIComponent(placeName)}&appname=com.matzip`, + web: `https://map.naver.com/p/search/${encodeURIComponent(placeName)}?c=${coords.longitude},${coords.latitude},18,0,0,0,dh`, +}) diff --git a/apps/web/app/places/[id]/PlaceDetailPage.tsx b/apps/web/app/places/[id]/PlaceDetailPage.tsx index 5e1e34fc..a8c043a9 100644 --- a/apps/web/app/places/[id]/PlaceDetailPage.tsx +++ b/apps/web/app/places/[id]/PlaceDetailPage.tsx @@ -62,7 +62,7 @@ export const PlaceDetailPage = ({ id }: { id: string }) => {
- +
diff --git a/apps/web/app/places/[id]/_components/Location/Location.tsx b/apps/web/app/places/[id]/_components/Location/Location.tsx index 503d83ed..b716709d 100644 --- a/apps/web/app/places/[id]/_components/Location/Location.tsx +++ b/apps/web/app/places/[id]/_components/Location/Location.tsx @@ -2,20 +2,91 @@ import { useState } from 'react' import { Container, NaverMap } from 'react-naver-maps' import { type Coord, toLatLng } from '@/map/_utils/toLatLng' import { PlaceMarker } from '@/map/_components/Marker' +import { Column } from '@repo/ui/components/Layout' +import { openNaverMap } from '@/_utils/openNaverMap' +import { openKakaoTaxi } from '@/_utils/openKakaoTaxi' +import Image from 'next/image' +import { cn } from '@repo/ui/utils/cn' -export const Location = ({ location }: { location: Coord }) => { +interface LocationProps { + location: Coord + placeName: string +} + +export const Location = ({ location, placeName }: LocationProps) => { const [, setMap] = useState(null) + const handleOpenNaverMap = () => { + openNaverMap({ + latitude: location.latitude, + longitude: location.longitude, + placeName, + }) + } + + const handleOpenKakaoTaxi = () => { + openKakaoTaxi({ + latitude: location.latitude, + longitude: location.longitude, + placeName, + }) + } + return ( - - - - - + + + + + + + + + + + ) } + +interface MapButtonProps { + onClick: VoidFunction + imageSrc: string + alt: string +} + +const MapButton = ({ onClick, imageSrc, alt }: MapButtonProps) => ( + +) diff --git a/apps/web/app/places/new/_components/Step/PlacePreview/Location.tsx b/apps/web/app/places/new/_components/Step/PlacePreview/Location.tsx new file mode 100644 index 00000000..5c156ec1 --- /dev/null +++ b/apps/web/app/places/new/_components/Step/PlacePreview/Location.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react' +import { Container, NaverMap } from 'react-naver-maps' +import { type Coord, toLatLng } from '@/map/_utils/toLatLng' +import { PlaceMarker } from '@/map/_components/Marker' +import { Column } from '@repo/ui/components/Layout' + +interface LocationProps { + location: Coord +} + +export const Location = ({ location }: LocationProps) => { + const [, setMap] = useState(null) + + return ( + + + + + + + + ) +} diff --git a/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx b/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx index 40c75d42..a0d1246e 100644 --- a/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx +++ b/apps/web/app/places/new/_components/Step/PlacePreview/PlacePreview.tsx @@ -4,7 +4,8 @@ import { useFormContext } from 'react-hook-form' import type { NewPlaceRequest } from '@/_apis/schemas/place' import { usePlaceQueries } from '@/_apis/queries/place' import { Carousel } from '@repo/ui/components/Carousel' -import { Location, Menus } from '@/places/[id]/_components' +import { Menus } from '@/places/[id]/_components' +import { Location } from './Location' import { Text } from '@repo/ui/components/Text' import { Button } from '@repo/ui/components/Button' import { Column, Flex, VerticalScrollArea } from '@repo/ui/components/Layout' diff --git a/apps/web/app/requests/[id]/RequestDetailPage.tsx b/apps/web/app/requests/[id]/RequestDetailPage.tsx index 8edcf671..89aac796 100644 --- a/apps/web/app/requests/[id]/RequestDetailPage.tsx +++ b/apps/web/app/requests/[id]/RequestDetailPage.tsx @@ -56,7 +56,7 @@ export const RequestDetailPage = ({ id }: { id: string }) => {
- +
diff --git a/apps/web/public/images/kakao-taxi-logo.png b/apps/web/public/images/kakao-taxi-logo.png new file mode 100644 index 00000000..68d3562e Binary files /dev/null and b/apps/web/public/images/kakao-taxi-logo.png differ diff --git a/apps/web/public/images/naver-map-logo.webp b/apps/web/public/images/naver-map-logo.webp new file mode 100644 index 00000000..b2ea9726 Binary files /dev/null and b/apps/web/public/images/naver-map-logo.webp differ