diff --git a/frontend/src/hooks/useOpenAppFromKakao.ts b/frontend/src/hooks/useOpenAppFromKakao.ts new file mode 100644 index 000000000..687f4209e --- /dev/null +++ b/frontend/src/hooks/useOpenAppFromKakao.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from 'react'; +import { APP_STORE_LINKS, detectPlatform } from '@/utils/appStoreLink'; + +const ANDROID_PACKAGE = 'com.moadong.moadong'; +const APP_HOST = 'www.moadong.com'; +const IOS_SCHEME = 'moadongapp'; + +const useOpenAppFromKakao = () => { + const [isLoading, setIsLoading] = useState(false); + const timerRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (timerRef.current !== null) clearTimeout(timerRef.current); + }; + }, []); + + const openApp = () => { + const platform = detectPlatform(); + const currentUrl = window.location.href; + + if (platform === 'Android') { + const url = new URL(currentUrl); + const fallback = encodeURIComponent(APP_STORE_LINKS.android); + const intentUrl = + `intent://${APP_HOST}${url.pathname}${url.search}${url.hash}` + + `#Intent;scheme=https;package=${ANDROID_PACKAGE};S.browser_fallback_url=${fallback};end`; + window.location.href = intentUrl; + return; + } + + if (timerRef.current !== null) clearTimeout(timerRef.current); + + setIsLoading(true); + + const url = new URL(currentUrl); + window.location.href = `${IOS_SCHEME}://${url.pathname}${url.search}${url.hash}`; + + timerRef.current = setTimeout(() => { + timerRef.current = null; + setIsLoading(false); + if (!document.hidden) { + window.location.href = `kakaotalk://web/openExternal?url=${encodeURIComponent(APP_STORE_LINKS.iphone)}`; + } + }, 2000); + // 2초 딜레이를 주는 이유는 앱 다운로드 페이지가 로드되는 시간을 주기 위함 + + document.addEventListener( + 'visibilitychange', + () => { + if (document.hidden && timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + setIsLoading(false); + } + }, + { once: true }, + ); + }; + + return { openApp, isLoading }; +}; + +export default useOpenAppFromKakao; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.styles.ts b/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.styles.ts index 6bffbb6bd..3c2f82bc0 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.styles.ts +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.styles.ts @@ -1,4 +1,5 @@ import styled from 'styled-components'; +import { Z_INDEX } from '@/styles/zIndex'; export const TopBarWrapper = styled.div<{ $isVisible: boolean }>` position: fixed; @@ -111,3 +112,30 @@ export const TabButton = styled.button<{ $active: boolean }>` cursor: pointer; transition: all 0.2s ease; `; + +export const LoadingOverlay = styled.div` + position: fixed; + inset: 0; + z-index: ${Z_INDEX.overlay}; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.3); +`; + +export const AppOpenButton = styled.button` + padding: 6px 12px; + border: none; + background-color: ${({ theme }) => theme.colors.base.black}; + color: ${({ theme }) => theme.colors.base.white}; + font-size: 13px; + font-weight: 600; + border-radius: 18px; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.2s ease; + + &:active { + opacity: 0.7; + } +`; diff --git a/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx b/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx index 5fdafacde..5893fe9f4 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubDetailTopBar/ClubDetailTopBar.tsx @@ -3,8 +3,11 @@ import { useNavigate } from 'react-router-dom'; import { useTheme } from 'styled-components'; import NotificationIcon from '@/assets/images/icons/notification_icon.svg?react'; import PrevButtonIcon from '@/assets/images/icons/prev_button_icon.svg?react'; +import Spinner from '@/components/common/Spinner/Spinner'; import { useScrollTrigger } from '@/hooks/Scroll/useScrollTrigger'; +import useOpenAppFromKakao from '@/hooks/useOpenAppFromKakao'; import isInAppWebView from '@/utils/isInAppWebView'; +import isKakaoTalkBrowser from '@/utils/isKakaoTalkBrowser'; import { requestNavigateBack, requestNotificationSubscribe, @@ -43,6 +46,8 @@ const ClubDetailTopBar = ({ const navigate = useNavigate(); const theme = useTheme(); const isInApp = isInAppWebView(); + const isKakao = !isInApp && isKakaoTalkBrowser(); + const { openApp, isLoading } = useOpenAppFromKakao(); const [isNotificationActive, setIsNotificationActive] = useState(initialIsSubscribed); @@ -118,6 +123,17 @@ const ClubDetailTopBar = ({ /> + ) : isKakao ? ( + <> + {isLoading && ( + + + + )} + openApp()}> + 앱열기 + + ) : ( )} diff --git a/frontend/src/utils/isKakaoTalkBrowser.ts b/frontend/src/utils/isKakaoTalkBrowser.ts new file mode 100644 index 000000000..9880fcac6 --- /dev/null +++ b/frontend/src/utils/isKakaoTalkBrowser.ts @@ -0,0 +1,3 @@ +const isKakaoTalkBrowser = () => /KAKAOTALK/i.test(navigator.userAgent); + +export default isKakaoTalkBrowser;