From 0e7ae4c659af663784e02e403f6cdf84b51d3000 Mon Sep 17 00:00:00 2001 From: durumi99 Date: Tue, 27 Jan 2026 23:20:46 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=B1=20=EC=84=A4=EC=B9=98=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/appDownload/appDownloadAlarm.svg | 11 + .../appDownload/appDownloadLightning.svg | 4 + src/assets/appDownload/appDownloadPhone.svg | 4 + .../AppInstallPopUp/AppInstallPopUp.style.ts | 203 ++++++++++++++++++ .../PopUp/AppInstallPopUp/AppInstallPopUp.tsx | 112 ++++++++++ src/hooks/useLocalStorageState.ts | 3 +- src/layout/Mainlayout/Mainlayout.tsx | 6 +- src/layout/SearchHeader/SearchHeader.tsx | 2 +- src/utils/Detector.ts | 6 +- 9 files changed, 347 insertions(+), 4 deletions(-) create mode 100644 src/assets/appDownload/appDownloadAlarm.svg create mode 100644 src/assets/appDownload/appDownloadLightning.svg create mode 100644 src/assets/appDownload/appDownloadPhone.svg create mode 100644 src/components/PopUp/AppInstallPopUp/AppInstallPopUp.style.ts create mode 100644 src/components/PopUp/AppInstallPopUp/AppInstallPopUp.tsx diff --git a/src/assets/appDownload/appDownloadAlarm.svg b/src/assets/appDownload/appDownloadAlarm.svg new file mode 100644 index 00000000..896ca447 --- /dev/null +++ b/src/assets/appDownload/appDownloadAlarm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/appDownload/appDownloadLightning.svg b/src/assets/appDownload/appDownloadLightning.svg new file mode 100644 index 00000000..1fe517d5 --- /dev/null +++ b/src/assets/appDownload/appDownloadLightning.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/appDownload/appDownloadPhone.svg b/src/assets/appDownload/appDownloadPhone.svg new file mode 100644 index 00000000..45688408 --- /dev/null +++ b/src/assets/appDownload/appDownloadPhone.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/PopUp/AppInstallPopUp/AppInstallPopUp.style.ts b/src/components/PopUp/AppInstallPopUp/AppInstallPopUp.style.ts new file mode 100644 index 00000000..2df43132 --- /dev/null +++ b/src/components/PopUp/AppInstallPopUp/AppInstallPopUp.style.ts @@ -0,0 +1,203 @@ +import styled from '@emotion/styled'; +import { theme } from '@styles/themes'; + +const Backdrop = styled('div')({ + position: 'fixed', + top: 0, + left: 0, + width: '100%', + height: '100%', + background: 'rgba(0, 0, 0, 0.5)', + zIndex: 999, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +const PopupContainer = styled.div({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: '24px 20px 16px', + gap: '28px', + width: '324px', + background: theme.colors.sub_white, + borderRadius: '20px', + zIndex: 1000, + boxSizing: 'border-box', +}); + +const PopupTextContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + padding: 0, + gap: '12px', + width: '284px', +}); + +const TitleContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + padding: 0, + gap: '2px', + width: '284px', +}); + +const SubTitle = styled.p({ + margin: 0, + width: '284px', + fontFamily: 'Pretendard', + ...theme.font.body16Medium, + color: theme.colors.sub_gray7, +}); + +const Title = styled.h2({ + margin: 0, + width: '284px', + fontFamily: 'Pretendard', + ...theme.font.title20Semibold, + color: theme.colors.sub_gray8, +}); + +const FeaturesContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + gap: '10px', + width: '284px', + borderRadius: '5px', +}); + +const FeatureItem = styled.div({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + padding: 0, + gap: '10px', + width: '284px', +}); + +const IconWrapper = styled.div({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + gap: '10px', + width: '32px', + height: '32px', + background: theme.colors.sub_blue5, + borderRadius: '500px', + flexShrink: 0, + + ['svg']: { + width: '18px', + height: '18px', + fill: theme.colors.sub_gray1, + }, +}); + +const FeatureTextContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + padding: 0, + flex: 1, +}); + +const FeatureLabel = styled.p({ + margin: 0, + fontFamily: 'Pretendard', + ...theme.font.detail12Medium, + color: theme.colors.sub_gray7, +}); + +const FeatureDescription = styled.p({ + margin: 0, + fontFamily: 'Pretendard', + ...theme.font.body14Semibold, + color: theme.colors.sub_gray10, +}); + +const ButtonContainer = styled.div({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + padding: 0, + gap: '12px', + width: '284px', +}); + +const CloseButton = styled.button({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + padding: '8px 0px', + gap: '10px', + width: '135px', + height: '48px', + background: theme.colors.sub_gray2, + borderRadius: '500px', + border: 'none', + cursor: 'pointer', + fontFamily: 'Pretendard', + ...theme.font.body18Semibold, + textAlign: 'center', + color: theme.colors.sub_gray8, + outline: 'none', + + ['&:active']: { + transform: 'scale(0.98)', + }, +}); + +const DownloadButton = styled.button({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + padding: '8px 0px', + gap: '10px', + width: '137px', + height: '48px', + background: theme.colors.sub_blue6, + borderRadius: '500px', + border: 'none', + cursor: 'pointer', + fontFamily: 'Pretendard', + ...theme.font.body18Semibold, + textAlign: 'center', + color: theme.colors.grayscale10, + outline: 'none', + + ['&:active']: { + transform: 'scale(0.98)', + }, +}); + +export { + Backdrop, + PopupContainer, + PopupTextContainer, + TitleContainer, + SubTitle, + Title, + FeaturesContainer, + FeatureItem, + IconWrapper, + FeatureTextContainer, + FeatureLabel, + FeatureDescription, + ButtonContainer, + CloseButton, + DownloadButton, +}; diff --git a/src/components/PopUp/AppInstallPopUp/AppInstallPopUp.tsx b/src/components/PopUp/AppInstallPopUp/AppInstallPopUp.tsx new file mode 100644 index 00000000..243bb6de --- /dev/null +++ b/src/components/PopUp/AppInstallPopUp/AppInstallPopUp.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import useLocalStorageState from '@hooks/useLocalStorageState'; +import AlarmIcon from '@assets/appDownload/appDownloadAlarm.svg?react'; +import LightningIcon from '@assets/appDownload/appDownloadLightning.svg?react'; +import PhoneIcon from '@assets/appDownload/appDownloadPhone.svg?react'; +import { + Backdrop, + ButtonContainer, + CloseButton, + DownloadButton, + FeatureDescription, + FeatureItem, + FeatureLabel, + FeatureTextContainer, + FeaturesContainer, + PopupContainer, + PopupTextContainer, + SubTitle, + Title, + TitleContainer, +} from './AppInstallPopUp.style'; + +interface AppInstallPopUpProps { + onClose?: () => void; + onDownload?: () => void; +} + +const AppInstallPopUp = ({ onClose, onDownload }: AppInstallPopUpProps) => { + const [lastShown, setLastShown] = useLocalStorageState('app_install_popup_last_shown'); + const [showPopUp, setShowPopUp] = useState( + (() => { + if (!lastShown) { + return true; + } + + const diff = new Date().getTime() - new Date(lastShown).getTime(); + // 24시간 (1일) 지났으면 다시 보여주기 + if (diff < 1000 * 60 * 60 * 24) { + return false; + } + + return true; + })(), + ); + + const handleClose = () => { + setLastShown(new Date().toISOString()); + setShowPopUp(false); + onClose?.(); + }; + + const handleDownload = () => { + setShowPopUp(false); + onDownload?.(); + // TODO: 실제 앱 다운로드 링크로 이동 + // 예: window.location.href = 'https://apps.apple.com/...' 또는 'https://play.google.com/...' + }; + + const handleBackdropClick = () => { + handleClose(); + }; + + if (!showPopUp) { + return null; + } + + return ( + + e.stopPropagation()}> + + + 앱에서 더 많은 기능을 경험해보세요! + 인간지표 앱 출시! + + + + + + + 알림 기능으로 + 시장 변화를 놓치지 않고! + + + + + + + 더 쉽고 빠르게 + 지표를 확인하고! + + + + + + + 앱 전용 기능까지 + 숏뷰기능으로 더 유용하게! + + + + + + + 닫기 + 앱 다운받기 + + + + ); +}; + +export default AppInstallPopUp; diff --git a/src/hooks/useLocalStorageState.ts b/src/hooks/useLocalStorageState.ts index 89d53789..26aece35 100644 --- a/src/hooks/useLocalStorageState.ts +++ b/src/hooks/useLocalStorageState.ts @@ -8,7 +8,8 @@ type LocalStorageKey = | 'recent_stocks' | 'tutorial_watched_shortview' | 'recent_provider' - | 'last_visit_page'; + | 'last_visit_page' + | 'app_install_popup_last_shown'; const useLocalStorageState = ( key: LocalStorageKey, diff --git a/src/layout/Mainlayout/Mainlayout.tsx b/src/layout/Mainlayout/Mainlayout.tsx index f8149810..cbe520f8 100644 --- a/src/layout/Mainlayout/Mainlayout.tsx +++ b/src/layout/Mainlayout/Mainlayout.tsx @@ -1,8 +1,9 @@ import { useLocation } from 'react-router-dom'; -import { detectPWA } from '@utils/Detector'; +import { detectPWA, detectPlatform, detectWebView } from '@utils/Detector'; import { webPath } from '@router/index'; import BottomNavigation from '@layout/BottomNavigation/BottomNavigation'; import Header from '@layout/Header/Header'; +import AppInstallPopUp from '@components/PopUp/AppInstallPopUp/AppInstallPopUp'; import PWAInfoPopUp from '@components/PopUp/PWAinfoPopUp/PWAInfoPopUp'; import Footer from '../Footer/Footer'; import { LayoutProps } from './Mainlayout.Props'; @@ -10,6 +11,8 @@ import { MainContent, StyledMainlayout } from './Mainlayout.Style'; const Mainlayout = ({ children }: LayoutProps) => { const location = useLocation(); + const platform = detectPlatform(); + const isMobileDevice = platform === 'iOS' || platform === 'Android'; const visiblePWAInfoPopUp = false; const isRootPage = location.pathname === '/'; @@ -28,6 +31,7 @@ const Mainlayout = ({ children }: LayoutProps) => { {visiblePWAInfoPopUp && isRootPage && !detectPWA() && } + {isMobileDevice && isRootPage && !detectWebView() && } {isBottomNavigationVisible && } ); diff --git a/src/layout/SearchHeader/SearchHeader.tsx b/src/layout/SearchHeader/SearchHeader.tsx index 36de67ca..84356b81 100644 --- a/src/layout/SearchHeader/SearchHeader.tsx +++ b/src/layout/SearchHeader/SearchHeader.tsx @@ -1,4 +1,3 @@ -import { MESSAGE_TYPES } from '../../config/webview'; import { useNavigate } from 'react-router-dom'; import useAuthInfo from '@hooks/useAuthInfo'; import useLocalStorageState from '@hooks/useLocalStorageState'; @@ -18,6 +17,7 @@ import HeartIcon from '@assets/icons/heart.svg?react'; import ToastBellSVG from '@assets/icons/toast/bell.svg?react'; import ToastBellCrossSVG from '@assets/icons/toast/bell_cross.svg?react'; import ToastHeartSVG from '@assets/icons/toast/heart.svg?react'; +import { MESSAGE_TYPES } from '../../config/webview'; import { IconButton, RightSection, SearchHeaderWrapper } from './SearchHeader.Style'; const SearchHeader = ({ stockInfo }: { stockInfo: StockDetailInfo }) => { diff --git a/src/utils/Detector.ts b/src/utils/Detector.ts index 79636a66..74f3995c 100644 --- a/src/utils/Detector.ts +++ b/src/utils/Detector.ts @@ -23,4 +23,8 @@ const detectPWA = () => { return window.matchMedia('(display-mode: standalone)').matches; }; -export { detectBrowser, detectPlatform, detectPWA }; +const detectWebView = () => { + return !!(window as any).ReactNativeWebView; +}; + +export { detectBrowser, detectPlatform, detectPWA, detectWebView };