From 9bcbace8817660c02c2f539d335fa66d33be33d5 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Sat, 21 Sep 2024 21:04:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B4=88=EB=8C=80=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?OS=20=EA=B3=B5=EC=9C=A0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 카카오 공유를 사용하기 위한 초기 설정 * feat: 모바일 디바이스를 감지할 수 있는 메서드 추가 * feat: 모바일 기기에서는 카카오톡 공유, 데스크탑에서는 텍스트 복사 유지 * style: 공유 텍스트 객체를 재사용 * fix: kakao api test 환경에선 돌아가지 않도록 설정 * chore: cypress-run node-env 추가 * fix: cypress에서 kakao init을 호출하지 않도록 mocking * fix: Cypress.on을 이용한 에러처리 무시; * fix: intercept 메서드 수정 * fix: test 환경이 아닌 경우 initialized도 읽지 않도록 변경 * fix: test 환경에선 돌아가지 않도록 부등호수정 * fix: window.kakao가 undefined일 경우 돌아가지 않도록 방어 * fix: 다시 blockKakao 복구 * style: member invite => share event로 컴포넌트 이름 변경 * refactor: event layout hook에서 share 책임 분리 * style: 카카오톡 공유 실행 함수 이름 변경 * style: useEventPageLayout hook 파일 이름 변경 * style: invite -> share로 용어 통일성 * refactor: isMobile 한 번만 호출되도록 변경 * fix: 변경된 구조로 인한 오류 수정 --- client/cypress/e2e/createEvent.cy.ts | 1 + client/cypress/support/commands.ts | 8 +++ client/index.html | 5 ++ client/src/App.tsx | 5 +- .../KakaoInitializer/KakaoInitializer.tsx | 15 ++++++ .../ShareEventButton/ShareEventButton.tsx | 41 +++++++++++++++ .../src/components/ShareEventButton/index.ts | 1 + client/src/global.d.ts | 1 + client/src/hooks/useEventPageLayout.ts | 23 +++++++++ client/src/hooks/useShareEvent.ts | 51 +++++++++++++++++++ .../src/pages/EventPage/EventPageLayout.tsx | 45 +++------------- client/src/types/kakao.d.ts | 38 ++++++++++++++ client/src/utils/isMobileDevice.ts | 8 +++ 13 files changed, 203 insertions(+), 39 deletions(-) create mode 100644 client/src/components/KakaoInitializer/KakaoInitializer.tsx create mode 100644 client/src/components/ShareEventButton/ShareEventButton.tsx create mode 100644 client/src/components/ShareEventButton/index.ts create mode 100644 client/src/hooks/useEventPageLayout.ts create mode 100644 client/src/hooks/useShareEvent.ts create mode 100644 client/src/types/kakao.d.ts create mode 100644 client/src/utils/isMobileDevice.ts diff --git a/client/cypress/e2e/createEvent.cy.ts b/client/cypress/e2e/createEvent.cy.ts index 5b4a93884..5de7f4592 100644 --- a/client/cypress/e2e/createEvent.cy.ts +++ b/client/cypress/e2e/createEvent.cy.ts @@ -1,6 +1,7 @@ import CONSTANTS from '../constants/constants'; beforeEach(() => { cy.blockSentry(); + cy.blockKakao(); }); describe('Flow: 랜딩 페이지에서부터 이벤트를 생성 완료하는 flow', () => { diff --git a/client/cypress/support/commands.ts b/client/cypress/support/commands.ts index c51d00580..70c8524ea 100644 --- a/client/cypress/support/commands.ts +++ b/client/cypress/support/commands.ts @@ -21,6 +21,13 @@ Cypress.Commands.add('blockSentry', () => { cy.intercept('POST', /.*sentry.io\/api.*/, {statusCode: 200}).as('sentry'); }); +Cypress.Commands.add('blockKakao', () => { + cy.intercept('GET', 'https://t1.kakaocdn.net/kakao_js_sdk/2.7.2/kakao.min.js', { + statusCode: 200, + body: '', + }).as('blockKakao'); +}); + Cypress.Commands.add('interceptAPI', ({type, delay = 0, statusCode = 200}: InterceptAPIProps) => { if (type === 'postEvent') cy.intercept(POST_EVENT, { @@ -49,6 +56,7 @@ declare global { namespace Cypress { interface Chainable { blockSentry(): Chainable; + blockKakao(): Chainable; interceptAPI(props: InterceptAPIProps): Chainable; createEventName(eventName: string): Chainable; } diff --git a/client/index.html b/client/index.html index 4a21293f2..5252bb218 100644 --- a/client/index.html +++ b/client/index.html @@ -25,6 +25,11 @@ window.amplitude.init('<%= process.env.AMPLITUDE_KEY %>'); }); + 행동대장 diff --git a/client/src/App.tsx b/client/src/App.tsx index c3815ad2a..4535c6b8a 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,7 @@ import {ReactQueryDevtools} from '@tanstack/react-query-devtools'; import {ToastProvider} from '@hooks/useToast/ToastProvider'; import QueryClientBoundary from '@components/QueryClientBoundary/QueryClientBoundary'; import ErrorCatcher from '@components/AppErrorBoundary/ErrorCatcher'; +import KakaoInitializer from '@components/KakaoInitializer/KakaoInitializer'; import {HDesignProvider} from '@HDesign/index'; @@ -20,7 +21,9 @@ const App: React.FC = () => { - + + + diff --git a/client/src/components/KakaoInitializer/KakaoInitializer.tsx b/client/src/components/KakaoInitializer/KakaoInitializer.tsx new file mode 100644 index 000000000..d3ed82bab --- /dev/null +++ b/client/src/components/KakaoInitializer/KakaoInitializer.tsx @@ -0,0 +1,15 @@ +import {useEffect} from 'react'; + +const KakaoInitializer = ({children}: React.PropsWithChildren) => { + useEffect(() => { + if (!window.Kakao) return; + + if (!window.Kakao.isInitialized()) { + window.Kakao.init(process.env.KAKAO_JAVASCRIPT_KEY); + } + }, []); + + return children; +}; + +export default KakaoInitializer; diff --git a/client/src/components/ShareEventButton/ShareEventButton.tsx b/client/src/components/ShareEventButton/ShareEventButton.tsx new file mode 100644 index 000000000..ff191a4cc --- /dev/null +++ b/client/src/components/ShareEventButton/ShareEventButton.tsx @@ -0,0 +1,41 @@ +import CopyToClipboard from 'react-copy-to-clipboard'; + +import {useToast} from '@hooks/useToast/useToast'; + +import useShareEvent from '@hooks/useShareEvent'; + +import {Button} from '@components/Design'; + +import isMobileDevice from '@utils/isMobileDevice'; + +const ShareEventButton = () => { + const {showToast} = useToast(); + + const isMobile = isMobileDevice(); + const {shareText, onShareButtonClick} = useShareEvent(isMobile); + + return isMobile ? ( + + ) : ( + + showToast({ + showingTime: 3000, + message: '링크가 복사되었어요 :) \n참여자들에게 링크를 공유해 주세요!', + type: 'confirm', + position: 'bottom', + bottom: '8rem', + }) + } + > + + + ); +}; + +export default ShareEventButton; diff --git a/client/src/components/ShareEventButton/index.ts b/client/src/components/ShareEventButton/index.ts new file mode 100644 index 000000000..27b5883aa --- /dev/null +++ b/client/src/components/ShareEventButton/index.ts @@ -0,0 +1 @@ +export {default as ShareEventButton} from './ShareEventButton'; diff --git a/client/src/global.d.ts b/client/src/global.d.ts index dbf6320af..ddc00fc99 100644 --- a/client/src/global.d.ts +++ b/client/src/global.d.ts @@ -7,5 +7,6 @@ declare namespace NodeJS { // env keys readonly API_BASE_URL: string; readonly AMPLITUDE_KEY: string; + readonly KAKAO_JAVASCRIPT_KEY: string; } } diff --git a/client/src/hooks/useEventPageLayout.ts b/client/src/hooks/useEventPageLayout.ts new file mode 100644 index 000000000..227ef402d --- /dev/null +++ b/client/src/hooks/useEventPageLayout.ts @@ -0,0 +1,23 @@ +import {useMatch} from 'react-router-dom'; + +import {ROUTER_URLS} from '@constants/routerUrls'; + +import useNavSwitch from './useNavSwitch'; +import useRequestGetEvent from './queries/event/useRequestGetEvent'; + +const useEventPageLayout = () => { + const navProps = useNavSwitch(); + const {eventName} = useRequestGetEvent(); + + const isAdmin = useMatch(ROUTER_URLS.eventManage) !== null; + const isLoginPage = useMatch(ROUTER_URLS.eventLogin) !== null; + + return { + navProps, + isAdmin, + eventName, + isLoginPage, + }; +}; + +export default useEventPageLayout; diff --git a/client/src/hooks/useShareEvent.ts b/client/src/hooks/useShareEvent.ts new file mode 100644 index 000000000..4a6ae1923 --- /dev/null +++ b/client/src/hooks/useShareEvent.ts @@ -0,0 +1,51 @@ +import getEventIdByUrl from '@utils/getEventIdByUrl'; +import getEventPageUrlByEnvironment from '@utils/getEventPageUrlByEnvironment'; + +import useRequestGetEvent from './queries/event/useRequestGetEvent'; + +const useShareEvent = (isMobile: boolean) => { + const {eventName} = useRequestGetEvent(); + + const eventId = getEventIdByUrl(); + const url = getEventPageUrlByEnvironment(eventId, 'home'); + + const shareInfo = { + title: `[행동대장]\n${eventName}에 대한 정산을 시작할게요:)`, + text: '아래 링크에 접속해서 정산 내역을 확인해 주세요!', + url, + }; + + // 모바일이 아닌 기기는 단순 텍스트 복사 + // 모바일 기기에서는 카카오톡 공유를 사용 + const onShareButtonClick = () => { + if (!isMobile) return; + + kakaoShare(); + }; + + const kakaoShare = () => { + window.Kakao.Share.sendDefault({ + objectType: 'feed', + content: { + title: shareInfo.title, + description: shareInfo.text, + imageUrl: + 'https://wooteco-crew-wiki.s3.ap-northeast-2.amazonaws.com/%EC%9B%A8%EB%94%94%286%EA%B8%B0%29/g583lirp8yg.jpg', + link: { + mobileWebUrl: url, + webUrl: url, + }, + }, + buttonTitle: '정산 확인하기', + }); + }; + + const shareText = `${shareInfo.title}\n${shareInfo.text}\n${url}`; + + return { + shareText, + onShareButtonClick, + }; +}; + +export default useShareEvent; diff --git a/client/src/pages/EventPage/EventPageLayout.tsx b/client/src/pages/EventPage/EventPageLayout.tsx index 3eb21a90e..1f33cdc01 100644 --- a/client/src/pages/EventPage/EventPageLayout.tsx +++ b/client/src/pages/EventPage/EventPageLayout.tsx @@ -1,17 +1,10 @@ -import {Outlet, useMatch} from 'react-router-dom'; -import CopyToClipboard from 'react-copy-to-clipboard'; +import {Outlet} from 'react-router-dom'; -import {useToast} from '@hooks/useToast/useToast'; -import useRequestGetEvent from '@hooks/queries/event/useRequestGetEvent'; +import useEventPageLayout from '@hooks/useEventPageLayout'; -import useNavSwitch from '@hooks/useNavSwitch'; +import {ShareEventButton} from '@components/ShareEventButton'; -import {MainLayout, TopNav, Switch, Button} from '@HDesign/index'; - -import getEventIdByUrl from '@utils/getEventIdByUrl'; -import getEventPageUrlByEnvironment from '@utils/getEventPageUrlByEnvironment'; - -import {ROUTER_URLS} from '@constants/routerUrls'; +import {MainLayout, TopNav, Switch} from '@HDesign/index'; export type EventPageContextProps = { isAdmin: boolean; @@ -19,43 +12,19 @@ export type EventPageContextProps = { }; const EventPageLayout = () => { - const {nav, paths, onChange} = useNavSwitch(); - const {eventName} = useRequestGetEvent(); - const eventId = getEventIdByUrl(); - - const isAdmin = useMatch(ROUTER_URLS.eventManage) !== null; - const isLoginPage = useMatch(ROUTER_URLS.eventLogin) !== null; + const {navProps, isAdmin, isLoginPage, eventName} = useEventPageLayout(); + const {nav, paths, onChange} = navProps; const outletContext: EventPageContextProps = { isAdmin, eventName, }; - const {showToast} = useToast(); - const url = getEventPageUrlByEnvironment(eventId, 'home'); - return ( - {!isLoginPage && ( - - showToast({ - showingTime: 3000, - message: '링크가 복사되었어요 :) \n참여자들에게 링크를 공유해 주세요!', - type: 'confirm', - position: 'bottom', - bottom: '8rem', - }) - } - > - - - )} + {!isLoginPage && } diff --git a/client/src/types/kakao.d.ts b/client/src/types/kakao.d.ts new file mode 100644 index 000000000..4d72a9833 --- /dev/null +++ b/client/src/types/kakao.d.ts @@ -0,0 +1,38 @@ +declare global { + interface Window { + Kakao: typeof Kakao; + } +} + +namespace Kakao { + function init(appKey: string): void; + function isInitialized(): boolean; + + namespace Share { + function cleanup(): void; + function sendDefault(params: sendDefaultParams): void; + + interface sendDefaultParams { + objectType: 'feed'; + content: { + title: string; + imageUrl: string; + imageWidth?: number; + imageHeight?: number; + description: string; + link: { + mobileWebUrl: string; + webUrl: string; + }; + }; + buttonTitle?: string; + buttons?: { + title: string; + link: { + webUrl: string; + mobileWebUrl: string; + }; + }[]; + } + } +} diff --git a/client/src/utils/isMobileDevice.ts b/client/src/utils/isMobileDevice.ts new file mode 100644 index 000000000..56e54f984 --- /dev/null +++ b/client/src/utils/isMobileDevice.ts @@ -0,0 +1,8 @@ +const isMobileDevice = () => { + const userAgent = window.navigator.userAgent; + const mobileRegex = [/Android/i, /iPhone/i, /iPad/i, /iPod/i, /BlackBerry/i, /Windows Phone/i]; + + return mobileRegex.some(mobile => userAgent.match(mobile)); +}; + +export default isMobileDevice;