From 616674e9b3ef51e44750d709103bca6e9a2a9334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=98=81=EA=B8=B8/KIM=20YOUNG=20GIL?= <80146176+Gilpop8663@users.noreply.github.com> Date: Sat, 12 Aug 2023 14:49:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=84=EC=9D=B4=ED=8F=B0=EC=9A=A9,=20?= =?UTF-8?q?=EC=95=88=EB=93=9C=EB=A1=9C=EC=9D=B4=EB=93=9C=EC=9A=A9=20?= =?UTF-8?q?=EC=96=B4=ED=94=8C=20=EC=84=A4=EC=B9=98=20=EC=97=AC=EB=B6=80?= =?UTF-8?q?=EB=A5=BC=20=ED=99=88=20=ED=99=94=EB=A9=B4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=EC=A3=BC=EA=B8=B0=20(feat.PWA)=20(#345)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: (#342) 안드로이드용 화면 구현 * feat: (#342) 아이폰용 화면 구현 * feat: (#342) 모바일 안드로이드, IOS에서 접속했을 경우 하단에 설치를 해달라는 문구 보이도록 구현 * style: (#342) 볼더 탑 연하게 수정, 큰 화면에서도 자연스럽게 보이도록 수정 * 사이트를 웹앱으로 실행되도록 하고, meta, favicon 설정 (#320) * feat: (#319) favicon 및 디바이스별로 보여줄 보투게더 로고 이미지 적용 * feat: (#319) 모바일 즐겨찾기 후 사용시 웹앱 네이티브로 보이도록 선언 아이패드가 켜질 때 로딩중 화면 설정 * feat: (#319) 라인, 카카오톡 공유시 사이트 정보를 미리볼 수 있도록 설정 * feat: (#319) 모바일 환경에서 사이트에 접근할 경우 홈으로 즐겨찾기 여부를 묻는 기능 구현 * refactor: (#319) 모바일 사용자에게 즐겨찾기를 묻는 함수 코드 가독성 개선 * fix: (#319) 모바일 디바이스에 물어보도록 수정 * feat: (#310) PWA(프로그레시브 웹 앱) 요소를 충족시키는 조건 설정 * chore: (#319) 사용하지 않는 코드 삭제 * chore: (#319) EOL을 위한 개행 추가 * refactor: (#342) beforeinstallprompt 이벤트에 대한 타입 선언 및 적용 * refactor: (#342) 사용자에게 보여주는 문구 수정 및 사용하지 않는 코드 삭제 --- frontend/src/assets/arrow-up-on-square.svg | 3 + frontend/src/assets/x_mark_black.svg | 3 + .../MobileInstallPrompt.stories.tsx | 30 +++++ .../MobileInstallPrompt/index.tsx | 49 ++++++++ .../MobileInstallPrompt/style.ts | 107 ++++++++++++++++++ .../common/AppInstallPrompt/index.tsx | 49 ++++++++ frontend/src/pages/Home/index.tsx | 2 + frontend/tsconfig.json | 9 +- frontend/window.d.ts | 14 +++ 9 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 frontend/src/assets/arrow-up-on-square.svg create mode 100644 frontend/src/assets/x_mark_black.svg create mode 100644 frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/MobileInstallPrompt.stories.tsx create mode 100644 frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/index.tsx create mode 100644 frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/style.ts create mode 100644 frontend/src/components/common/AppInstallPrompt/index.tsx create mode 100644 frontend/window.d.ts diff --git a/frontend/src/assets/arrow-up-on-square.svg b/frontend/src/assets/arrow-up-on-square.svg new file mode 100644 index 000000000..c6eca45db --- /dev/null +++ b/frontend/src/assets/arrow-up-on-square.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/assets/x_mark_black.svg b/frontend/src/assets/x_mark_black.svg new file mode 100644 index 000000000..457d8e626 --- /dev/null +++ b/frontend/src/assets/x_mark_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/MobileInstallPrompt.stories.tsx b/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/MobileInstallPrompt.stories.tsx new file mode 100644 index 000000000..5de676e8d --- /dev/null +++ b/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/MobileInstallPrompt.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import MobileInstallPrompt from '.'; + +const meta: Meta = { + component: MobileInstallPrompt, +}; + +export default meta; +type Story = StoryObj; + +export const Android: Story = { + render: () => ( + {}} + handleCancelClick={() => {}} + /> + ), +}; + +export const Ios: Story = { + render: () => ( + {}} + handleCancelClick={() => {}} + /> + ), +}; diff --git a/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/index.tsx b/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/index.tsx new file mode 100644 index 000000000..973374b8e --- /dev/null +++ b/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/index.tsx @@ -0,0 +1,49 @@ +import arrowUp from '@assets/arrow-up-on-square.svg'; +import logo from '@assets/logo.svg'; +import cancel from '@assets/x_mark_black.svg'; + +import * as S from './style'; + +interface MobileInstallPromptProps { + platform: 'ios' | 'android'; + handleInstallClick: () => void; + handleCancelClick: () => void; +} + +export default function MobileInstallPrompt({ + platform, + handleInstallClick, + handleCancelClick, +}: MobileInstallPromptProps) { + return ( + + + + + + + VoTogether + + + + + + VoTogether는 앱처럼 원활히 사용할 수 있습니다. 설치하시겠습니까? + + + + {platform === 'ios' && ( + + + 브라우저 메뉴바에서 모양 버튼을 + 눌러 "홈 화면에 추가하기"를 통해 설치를 할 수 있습니다. + + + )} + {platform === 'android' && ( + 홈 화면에 추가 + )} + + + ); +} diff --git a/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/style.ts b/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/style.ts new file mode 100644 index 000000000..30e444909 --- /dev/null +++ b/frontend/src/components/common/AppInstallPrompt/MobileInstallPrompt/style.ts @@ -0,0 +1,107 @@ +import { styled } from 'styled-components'; + +import { theme } from '@styles/theme'; + +export const Container = styled.div` + display: flex; + justify-content: center; + align-items: center; + + width: 100vw; + border-top: 1px solid rgba(0, 0, 0, 0.3); + + position: fixed; + bottom: 0; + left: 0; + + background-color: white; + + z-index: ${theme.zIndex.modal}; +`; + +export const Content = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + + width: max-content; + padding: 30px 20px; +`; + +export const Header = styled.div` + display: flex; + justify-content: space-between; + + margin-bottom: 50px; +`; + +export const LogoImage = styled.img` + border-radius: 16px; + + width: 80px; + height: 80px; +`; + +export const HeaderContent = styled.div` + display: flex; + flex-direction: column; + + margin-left: 24px; +`; + +export const HeaderTop = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + margin-bottom: 10px; +`; + +export const Title = styled.span` + font-size: 2.4rem; + font-weight: 700; +`; + +export const Description = styled.p` + font-size: 1.6rem; + font-weight: 700; +`; + +export const CancelButton = styled.button` + padding: 10px; + + position: relative; + bottom: 10px; + left: 10px; + + cursor: pointer; +`; + +export const IconImage = styled.img` + width: 24px; + height: 24px; +`; + +export const InstallButton = styled.button` + align-self: end; + + border-radius: 6px; + + width: 190px; + height: 40px; + + font-size: 1.6rem; + font-weight: 500; + + color: white; + background-color: #5383ed; + + cursor: pointer; +`; + +export const IosContainer = styled.div` + display: flex; + align-items: center; + align-self: end; + gap: 8px; +`; diff --git a/frontend/src/components/common/AppInstallPrompt/index.tsx b/frontend/src/components/common/AppInstallPrompt/index.tsx new file mode 100644 index 000000000..68c0e23f7 --- /dev/null +++ b/frontend/src/components/common/AppInstallPrompt/index.tsx @@ -0,0 +1,49 @@ +import { Fragment, useEffect, useState } from 'react'; + +import { BeforeInstallPromptEvent } from '../../../../window'; + +import MobileInstallPrompt from './MobileInstallPrompt'; + +export default function AppInstallPrompt() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const isDeviceIOS = /iPad|iPhone|iPod/.test(window.navigator.userAgent); + + useEffect(() => { + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + }; + }, []); + + const handleBeforeInstallPrompt = (event: BeforeInstallPromptEvent) => { + event.preventDefault(); + setDeferredPrompt(event); + }; + + const handleInstallClick = () => { + if (deferredPrompt) { + deferredPrompt.prompt(); + + deferredPrompt.userChoice.then(() => { + setDeferredPrompt(null); + }); + } + }; + + const handleCancelClick = () => { + setDeferredPrompt(null); + }; + + return ( + + {deferredPrompt && ( + + )} + + ); +} diff --git a/frontend/src/pages/Home/index.tsx b/frontend/src/pages/Home/index.tsx index 006adadb6..7590b4bb8 100644 --- a/frontend/src/pages/Home/index.tsx +++ b/frontend/src/pages/Home/index.tsx @@ -1,3 +1,4 @@ +import AppInstallPrompt from '@components/common/AppInstallPrompt'; import Layout from '@components/common/Layout'; import PostListPage from '@components/post/PostListPage'; @@ -5,6 +6,7 @@ export default function Home() { return ( + ); } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 23c9de5d5..63e5fb99d 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -33,6 +33,13 @@ }, "outDir": "./dist" }, - "include": ["src", "src/custom.d.ts", "__test__", "styled-components.d.ts", "env.d.ts"], + "include": [ + "src", + "src/custom.d.ts", + "__test__", + "styled-components.d.ts", + "env.d.ts", + "window.d.ts" + ], "exclude": ["node_modules"] } diff --git a/frontend/window.d.ts b/frontend/window.d.ts new file mode 100644 index 000000000..284bd9cce --- /dev/null +++ b/frontend/window.d.ts @@ -0,0 +1,14 @@ +export interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[]; + readonly userChoice: Promise<{ + outcome: 'accepted' | 'dismissed'; + platform: string; + }>; + prompt(): Promise; +} + +declare global { + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + } +}