From 986bcf142996a785d45cda40c7a222db330624be Mon Sep 17 00:00:00 2001 From: Soyeon Choe <77609591+soi-ha@users.noreply.github.com> Date: Wed, 2 Oct 2024 13:56:51 +0900 Subject: [PATCH 01/20] =?UTF-8?q?fix:=20=EC=A0=95=EC=82=B0=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=ED=95=98=EA=B8=B0=EC=97=90=20=EB=88=84=EB=9D=BD?= =?UTF-8?q?=EB=90=9C=20margin=20=EC=A0=81=EC=9A=A9=20(#682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/ShareEventButton/ShareEventButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ShareEventButton/ShareEventButton.tsx b/client/src/components/ShareEventButton/ShareEventButton.tsx index f8a968003..015200be4 100644 --- a/client/src/components/ShareEventButton/ShareEventButton.tsx +++ b/client/src/components/ShareEventButton/ShareEventButton.tsx @@ -52,7 +52,7 @@ const ShareEventButton = ({eventOutline}: ShareEventButtonProps) => { }) } > - <Button size="small" variants="tertiary" onClick={induceBankInfoBeforeShare}> + <Button size="small" variants="tertiary" onClick={induceBankInfoBeforeShare} style={{marginRight: '1rem'}}> 정산 초대하기 </Button> </CopyToClipboard> From 5d5d9f182d6660985d69859f2e230c8bcf429c99 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Wed, 2 Oct 2024 13:58:10 +0900 Subject: [PATCH 02/20] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=20api=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20(#686)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: event loader를 이용해서 이벤트에 필요한 정보 병렬적으로 데이터 불러옴 * refactor: 총 금액 업데이트 계산을 로더에서 제거 --- client/src/components/Loader/EventLoader.tsx | 34 ++++++++++++++++++++ client/src/router.tsx | 12 +++++-- 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 client/src/components/Loader/EventLoader.tsx diff --git a/client/src/components/Loader/EventLoader.tsx b/client/src/components/Loader/EventLoader.tsx new file mode 100644 index 000000000..c79337319 --- /dev/null +++ b/client/src/components/Loader/EventLoader.tsx @@ -0,0 +1,34 @@ +import {useQueries} from '@tanstack/react-query'; + +import {requestGetEvent} from '@apis/request/event'; +import {requestGetReports} from '@apis/request/report'; +import {requestGetSteps} from '@apis/request/step'; +import {WithErrorHandlingStrategy} from '@errors/RequestGetError'; + +import getEventIdByUrl from '@utils/getEventIdByUrl'; + +import QUERY_KEYS from '@constants/queryKeys'; + +const EventLoader = ({children, ...props}: React.PropsWithChildren<WithErrorHandlingStrategy | null> = {}) => { + const eventId = getEventIdByUrl(); + + const queries = useQueries({ + queries: [ + {queryKey: [QUERY_KEYS.event], queryFn: () => requestGetEvent({eventId, ...props})}, + { + queryKey: [QUERY_KEYS.reports], + queryFn: () => requestGetReports({eventId, ...props}), + }, + { + queryKey: [QUERY_KEYS.steps], + queryFn: () => requestGetSteps({eventId, ...props}), + }, + ], + }); + + const isLoading = queries.some(query => query.isLoading === true); + + return !isLoading && children; +}; + +export default EventLoader; diff --git a/client/src/router.tsx b/client/src/router.tsx index f78874f65..81b4ad2cd 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -3,6 +3,7 @@ import {lazy, Suspense} from 'react'; import ErrorPage from '@pages/ErrorPage/ErrorPage'; import EventLoginPage from '@pages/EventPage/AdminPage/EventLoginPage'; +import EventLoader from '@components/Loader/EventLoader'; import {EventPage} from '@pages/EventPage'; @@ -40,9 +41,16 @@ const router = createBrowserRouter([ }, { path: ROUTER_URLS.event, - element: <EventPage />, + element: ( + <EventLoader> + <EventPage /> + </EventLoader> + ), children: [ - {path: ROUTER_URLS.eventManage, element: <AdminPage />}, + { + path: ROUTER_URLS.eventManage, + element: <AdminPage />, + }, {path: ROUTER_URLS.home, element: <HomePage />}, { path: ROUTER_URLS.eventLogin, From a491203ccba2d74c68c870d3cf84e1f8bef15610 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:00:07 +0900 Subject: [PATCH 03/20] =?UTF-8?q?feat:=20=ED=86=A0=EC=8A=A4=20=EC=86=A1?= =?UTF-8?q?=EA=B8=88=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(#688)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: prop 변경으로 스토리북 변경 * refactor: 클립보드 복사 후 토스 열기에서 토스 url스킴을 자세히 이용해 송금정보 자동 입력되도록 변경 --- ...elect.stories.tsx => BankSend.stories.tsx} | 1 - .../BankSendButton/BankSendButton.tsx | 28 ++++++------------- .../ExpenseList/ExpenseList.stories.tsx | 12 +++----- .../components/ExpenseList/ExpenseList.tsx | 15 ++-------- .../ExpenseList/ExpenseList.type.ts | 3 +- client/src/hooks/useReportsPage.ts | 18 ++---------- 6 files changed, 19 insertions(+), 58 deletions(-) rename client/src/components/Design/components/BankSendButton/{BankSelect.stories.tsx => BankSend.stories.tsx} (92%) diff --git a/client/src/components/Design/components/BankSendButton/BankSelect.stories.tsx b/client/src/components/Design/components/BankSendButton/BankSend.stories.tsx similarity index 92% rename from client/src/components/Design/components/BankSendButton/BankSelect.stories.tsx rename to client/src/components/Design/components/BankSendButton/BankSend.stories.tsx index 3d68811ae..153f890d0 100644 --- a/client/src/components/Design/components/BankSendButton/BankSelect.stories.tsx +++ b/client/src/components/Design/components/BankSendButton/BankSend.stories.tsx @@ -17,7 +17,6 @@ const meta = { }, }, args: { - clipboardText: '토스뱅크 010100-10-123123', onBankButtonClick: () => console.log('안녕'), }, } satisfies Meta<typeof BankSendButton>; diff --git a/client/src/components/Design/components/BankSendButton/BankSendButton.tsx b/client/src/components/Design/components/BankSendButton/BankSendButton.tsx index 3351b7dcc..893b7cf3d 100644 --- a/client/src/components/Design/components/BankSendButton/BankSendButton.tsx +++ b/client/src/components/Design/components/BankSendButton/BankSendButton.tsx @@ -1,6 +1,4 @@ /** @jsxImportSource @emotion/react */ -import CopyToClipboard from 'react-copy-to-clipboard'; - import {useTheme} from '@components/Design/theme/HDesignProvider'; import Icon from '../Icon/Icon'; @@ -10,17 +8,11 @@ import Flex from '../Flex/Flex'; import {bankButtonStyle, isDepositedStyle} from './BankSendButton.style'; type BankSendButtonProps = React.HTMLAttributes<HTMLButtonElement> & { - clipboardText: string; onBankButtonClick: () => void; isDeposited?: boolean; }; -const BankSendButton = ({ - clipboardText, - onBankButtonClick, - isDeposited = false, - ...buttonProps -}: BankSendButtonProps) => { +const BankSendButton = ({onBankButtonClick, isDeposited = false, ...buttonProps}: BankSendButtonProps) => { const {theme} = useTheme(); return isDeposited ? ( @@ -32,16 +24,14 @@ const BankSendButton = ({ </Flex> </button> ) : ( - <CopyToClipboard text={clipboardText} onCopy={onBankButtonClick}> - <button css={bankButtonStyle(theme)} {...buttonProps}> - <Flex justifyContent="center" alignItems="center" gap="0.125rem"> - <Text size="tiny" textColor="black"> - 송금 - </Text> - <Icon iconType="toss" /> - </Flex> - </button> - </CopyToClipboard> + <button onClick={onBankButtonClick} css={bankButtonStyle(theme)} {...buttonProps}> + <Flex justifyContent="center" alignItems="center" gap="0.125rem"> + <Text size="tiny" textColor="black"> + 송금 + </Text> + <Icon iconType="toss" /> + </Flex> + </button> ); }; diff --git a/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx b/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx index ddaf1d50b..830e84206 100644 --- a/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx +++ b/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx @@ -23,32 +23,28 @@ const meta = { memberName: '소하', price: 2000, isDeposited: true, - clipboardText: '토스은행 2000원', - onBankButtonClick: () => console.log('소하'), + onBankButtonClick: (amount: number) => console.log(amount), }, { memberId: 2, memberName: '토다리', price: 2000, isDeposited: false, - clipboardText: '토스은행 2000원', - onBankButtonClick: () => console.log('토다리'), + onBankButtonClick: (amount: number) => console.log(amount), }, { memberId: 3, memberName: '웨디', price: 1080, isDeposited: true, - clipboardText: '토스은행 1080원', - onBankButtonClick: () => console.log('웨디'), + onBankButtonClick: (amount: number) => console.log(amount), }, { memberId: 4, memberName: '쿠키', price: 3020, isDeposited: false, - clipboardText: '토스은행 3020원', - onBankButtonClick: () => console.log('쿠키'), + onBankButtonClick: (amount: number) => console.log(amount), }, ], }, diff --git a/client/src/components/Design/components/ExpenseList/ExpenseList.tsx b/client/src/components/Design/components/ExpenseList/ExpenseList.tsx index 4741780a3..23f6e2fdf 100644 --- a/client/src/components/Design/components/ExpenseList/ExpenseList.tsx +++ b/client/src/components/Design/components/ExpenseList/ExpenseList.tsx @@ -13,14 +13,7 @@ import DepositCheck from '../DepositCheck/DepositCheck'; import {ExpenseItemProps, ExpenseListProps} from './ExpenseList.type'; -function ExpenseItem({ - memberName, - price, - isDeposited, - onBankButtonClick, - clipboardText, - ...divProps -}: ExpenseItemProps) { +function ExpenseItem({memberName, price, isDeposited, onBankButtonClick, ...divProps}: ExpenseItemProps) { return ( <Flex justifyContent="spaceBetween" @@ -39,11 +32,7 @@ function ExpenseItem({ <Flex alignItems="center" gap="0.5rem"> <Amount amount={price} /> {isMobileDevice() ? ( - <BankSendButton - clipboardText={clipboardText} - onBankButtonClick={onBankButtonClick} - isDeposited={price <= 0 || isDeposited} - /> + <BankSendButton onBankButtonClick={() => onBankButtonClick(price)} isDeposited={price <= 0 || isDeposited} /> ) : ( <IconButton variants="none" size="small"> <Icon iconType="rightChevron" /> diff --git a/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts b/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts index 90a44c842..7ac189579 100644 --- a/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts +++ b/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts @@ -1,8 +1,7 @@ import {Report} from 'types/serviceType'; export type ExpenseItemCustomProps = Report & { - onBankButtonClick: () => void; - clipboardText: string; + onBankButtonClick: (amount: number) => void; }; export type ExpenseItemProps = React.ComponentProps<'div'> & ExpenseItemCustomProps; diff --git a/client/src/hooks/useReportsPage.ts b/client/src/hooks/useReportsPage.ts index 87ab2c59f..27083b297 100644 --- a/client/src/hooks/useReportsPage.ts +++ b/client/src/hooks/useReportsPage.ts @@ -3,8 +3,6 @@ import {useOutletContext} from 'react-router-dom'; import {EventPageContextProps} from '@pages/EventPage/EventPageLayout'; -import {isAndroid, isIOS} from '@utils/detectDevice'; - import {ERROR_MESSAGE} from '@constants/errorMessage'; import {useSearchReports} from './useSearchReports'; @@ -19,7 +17,7 @@ const useReportsPage = () => { setMemberName(target.value); }; - const onBankButtonClick = () => { + const onBankButtonClick = (amount: number) => { if (bankName.trim() === '' || accountNumber.trim() === '') { toast.error(ERROR_MESSAGE.emptyBank, { showingTime: 3000, @@ -28,22 +26,12 @@ const useReportsPage = () => { return; } - if (isAndroid()) { - const url = 'supertoss://'; - window.location.href = url; - return; - } - - if (isIOS()) { - const url = 'supertoss://send'; - window.location.href = url; - return; - } + const url = `supertoss://send?amount=${amount}&bank=${bankName}&accountNo=${accountNumber}`; + window.location.href = url; }; const expenseListProp = matchedReports.map(member => ({ ...member, - clipboardText: `${bankName} ${accountNumber} ${member.price}원`, onBankButtonClick, })); From d78b5086360b0168784c79011c8f69a8532069bc Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:00:48 +0900 Subject: [PATCH 04/20] =?UTF-8?q?fix:=20=EA=B3=84=EC=A2=8C=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=88=98=EC=A0=95=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#691)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/hooks/useAccount.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/client/src/hooks/useAccount.ts b/client/src/hooks/useAccount.ts index 418656cc5..ed1315699 100644 --- a/client/src/hooks/useAccount.ts +++ b/client/src/hooks/useAccount.ts @@ -57,22 +57,8 @@ const useAccount = () => { setTimeout(() => setIsPasting(false), 0); }; - const getChangedField = () => { - const changedField: Partial<Event> = {}; - - if (bankNameState.trim() !== '' && bankName !== bankNameState) { - changedField.bankName = bankNameState; - } - - if (accountNumberState.trim() !== '' && accountNumber !== accountNumberState) { - changedField.accountNumber = accountNumberState; - } - - return changedField; - }; - const enrollAccount = async () => { - await patchEventOutline(getChangedField()); + await patchEventOutline({bankName: bankNameState, accountNumber: accountNumberState}); }; useEffect(() => { From 5be47d32c636ccbf225bca5a630bc8f49e7c1389 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Wed, 2 Oct 2024 14:01:36 +0900 Subject: [PATCH 05/20] =?UTF-8?q?feat:=20staleTime,=20gcTime=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/QueryClientBoundary/QueryClientBoundary.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/src/components/QueryClientBoundary/QueryClientBoundary.tsx b/client/src/components/QueryClientBoundary/QueryClientBoundary.tsx index 0ff8bf202..c39f37f85 100644 --- a/client/src/components/QueryClientBoundary/QueryClientBoundary.tsx +++ b/client/src/components/QueryClientBoundary/QueryClientBoundary.tsx @@ -12,6 +12,11 @@ const QueryClientBoundary = ({children}: React.PropsWithChildren) => { defaultOptions: { queries: { throwOnError: true, + + staleTime: 1000 * 60, // 1 minute + gcTime: 1000 * 60, // 1 minute + + refetchOnWindowFocus: false, // window focus가 다시 일어났을 때 refetch하지 않음 }, }, queryCache: new QueryCache({ From 089d59329d119873325be9aa7243b47f66dc5068 Mon Sep 17 00:00:00 2001 From: TaehunLee <85233397+Todari@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:53:24 +0900 Subject: [PATCH 06/20] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EC=B5=9C=EC=A0=81=ED=99=94=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20meta=20=ED=83=9C=EA=B7=B8=20=EC=9E=91=EC=84=B1=20(#?= =?UTF-8?q?696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: meta 태그 설정 * fix: 잘못 설정된 og:locale 태그 수정 --- client/index.html | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/client/index.html b/client/index.html index 5252bb218..a7a7106c3 100644 --- a/client/index.html +++ b/client/index.html @@ -2,20 +2,40 @@ <html lang="ko"> <head> <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0" /> - <meta content="upgrade-insecure-requests" /> - <meta name="haengdong" content="행동대장으로 간편하게 정산하세요" /> - <meta property="og:url" content="https://app.haengdong.pro" /> - <meta property="og:title" content="행동대장" /> + + <title>행동대장 - 쉽고 빠른 모임 정산 및 송금 서비스</title> + + <meta name="description" content="모임에서 발생한 비용을 손쉽게 정산하고 간편하게 송금할 수 있는 행동대장" /> + <meta + name="keywords" + content="행동대장, 행대동장, 행댕이, 흔듯, 행사, 정산, 모임, 송금, 더치페이, 더치페이 서비스, 더치페이 앱, 간편 정산, 간편한 정산, 쉬운 정산, 정산 앱, 정산 서비스, 엔빵, 엔빵계산기, 엔빵 앱, 엔빵 서비스, 우아한테크코스, 우테코, 우테코 프로젝트, 우테코 6기, 우아한테크코스 6기" + /> + + <meta property="og:url" content="https://haengdong.pro/" /> + <meta property="og:title" content="행동대장 - 쉽고 빠른 모임 정산 및 송금 서비스" /> + <meta + property="og:description" + content="모임에서 발생한 비용을 실시간으로 기록하고 정산하여 더치페이 결과를 간편하게 송금할 수 있는 행동대장" + /> <meta property="og:type" content="website" /> - <meta property="og:description" content="행동대장으로 간편하게 정산하세요" /> <meta property="og:image" content="https://wooteco-crew-wiki.s3.ap-northeast-2.amazonaws.com/%EC%BF%A0%ED%82%A4%286%EA%B8%B0%29/4tyq1x19rsn.jpg" /> <meta property="og:image:type" content="image/png" /> <meta property="og:image:alt" content="행댕이" /> + <meta property="og:locale" content="ko_KR" /> + + <meta name="naver-site-verification" content="fc844c0b28550c1428ae73b20cc8cb9434deae1a" /> + + <meta http-equiv="Content-Language" content="ko" /> + <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests" /> + + <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=0" /> + + <link rel="canonical" href="https://haengdong.pro/" /> <link rel="icon" href="favicon.ico" type="image/x-icon" /> + <script src="https://cdn.amplitude.com/libs/analytics-browser-2.9.3-min.js.gz"></script> <script src="https://cdn.amplitude.com/libs/plugin-session-replay-browser-1.6.8-min.js.gz"></script> <script src="https://cdn.amplitude.com/libs/plugin-autocapture-browser-1.0.0-min.js.gz"></script> @@ -30,7 +50,6 @@ integrity="sha384-TiCUE00h649CAMonG018J2ujOgDKW/kVWlChEuu4jK2vxfAAD0eZxzCKakxg55G4" crossorigin="anonymous" ></script> - <title>행동대장</title> </head> <body> <div id="root"></div> From 4b4dc7921cd450fa86f4662e7e777de506967c2d Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:54:40 +0900 Subject: [PATCH 07/20] =?UTF-8?q?feat:=20TopNav=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EA=B0=95=EC=A1=B0=20=EB=B0=8F=20=EA=B3=84=EC=A2=8C=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=A0=91=EA=B7=BC=20=EA=B4=80=EB=A0=A8=20(#697)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: event login 페이지 제거 * feat: auth gate를 통해 관리자인지 체크할 수 있도록 변경 * feat: 관리자가 아니고 계좌번호가 입력되지 않았을 때, 정산자에게 문의를 하라는 토스트 * style: eventOutline -> event로 이름 변경 * refactor: 초대버튼의 역할을 외부로 분리해서 주입해주는 것으로 변경 --- .../DesktopShareEventButton.tsx | 30 +++++++++ .../MobileShareEventButton.tsx | 15 +++++ .../ShareEventButton/ShareEventButton.tsx | 62 ------------------- .../src/components/ShareEventButton/index.ts | 3 +- client/src/constants/routerUrls.ts | 1 - .../auth/useRequestPostAuthentication.ts | 12 ++-- .../hooks/queries/auth/useRequestPostLogin.ts | 11 ++-- client/src/hooks/useEventPageLayout.ts | 13 ++-- client/src/hooks/useShareEvent.ts | 56 ++++++++++++++--- .../pages/EventPage/AdminPage/AdminPage.tsx | 7 --- client/src/pages/EventPage/AuthGate/index.tsx | 26 ++++++++ .../src/pages/EventPage/EventPageLayout.tsx | 20 ++++-- client/src/router.tsx | 11 ++-- client/src/store/authStore.ts | 14 +++++ 14 files changed, 167 insertions(+), 114 deletions(-) create mode 100644 client/src/components/ShareEventButton/DesktopShareEventButton.tsx create mode 100644 client/src/components/ShareEventButton/MobileShareEventButton.tsx delete mode 100644 client/src/components/ShareEventButton/ShareEventButton.tsx create mode 100644 client/src/pages/EventPage/AuthGate/index.tsx create mode 100644 client/src/store/authStore.ts diff --git a/client/src/components/ShareEventButton/DesktopShareEventButton.tsx b/client/src/components/ShareEventButton/DesktopShareEventButton.tsx new file mode 100644 index 000000000..4063b77ad --- /dev/null +++ b/client/src/components/ShareEventButton/DesktopShareEventButton.tsx @@ -0,0 +1,30 @@ +import CopyToClipboard from 'react-copy-to-clipboard'; + +import toast from '@hooks/useToast/toast'; + +import {Button} from '@components/Design'; + +type DesktopShareEventButtonProps = React.HTMLAttributes<HTMLButtonElement> & { + shareText: string; + text: string; +}; + +const DesktopShareEventButton = ({shareText, text, onClick}: DesktopShareEventButtonProps) => { + return ( + <CopyToClipboard + text={shareText} + onCopy={() => + toast.confirm('링크가 복사되었어요 :) \n참여자들에게 링크를 공유해 주세요!', { + showingTime: 3000, + position: 'bottom', + }) + } + > + <Button size="small" variants="tertiary" onClick={onClick} style={{marginRight: '1rem'}}> + {text} + </Button> + </CopyToClipboard> + ); +}; + +export default DesktopShareEventButton; diff --git a/client/src/components/ShareEventButton/MobileShareEventButton.tsx b/client/src/components/ShareEventButton/MobileShareEventButton.tsx new file mode 100644 index 000000000..8526380e4 --- /dev/null +++ b/client/src/components/ShareEventButton/MobileShareEventButton.tsx @@ -0,0 +1,15 @@ +import {Button} from '@components/Design'; + +type MobileShareEventButtonProps = React.HTMLAttributes<HTMLButtonElement> & { + text: string; +}; + +const MobileShareEventButton = ({text, ...buttonProps}: MobileShareEventButtonProps) => { + return ( + <Button size="small" variants="tertiary" style={{marginRight: '1rem'}} {...buttonProps}> + {text} + </Button> + ); +}; + +export default MobileShareEventButton; diff --git a/client/src/components/ShareEventButton/ShareEventButton.tsx b/client/src/components/ShareEventButton/ShareEventButton.tsx deleted file mode 100644 index 015200be4..000000000 --- a/client/src/components/ShareEventButton/ShareEventButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import CopyToClipboard from 'react-copy-to-clipboard'; -import {useNavigate} from 'react-router-dom'; - -import toast from '@hooks/useToast/toast'; -import {Event} from 'types/serviceType'; - -import useShareEvent from '@hooks/useShareEvent'; - -import {Button} from '@components/Design'; - -import {isMobileDevice} from '@utils/detectDevice'; -import getDeletedLastPath from '@utils/getDeletedLastPath'; - -type ShareEventButtonProps = { - eventOutline: Event; -}; - -const ShareEventButton = ({eventOutline}: ShareEventButtonProps) => { - const {eventName, bankName, accountNumber} = eventOutline; - const navigate = useNavigate(); - - const isMobile = isMobileDevice(); - const {shareText, onShareButtonClick} = useShareEvent(eventName, isMobile); - - const induceBankInfoBeforeShare = () => { - if (bankName === '' || accountNumber === '') { - toast.confirm('잠깐! 정산을 초대하기 전에\n계좌를 등록해주세요', { - showingTime: 3000, - position: 'bottom', - }); - - const navigatePath = `${getDeletedLastPath(location.pathname)}/admin/edit`; - - navigate(navigatePath); - return; - } - - onShareButtonClick(); - }; - - return isMobile ? ( - <Button size="small" variants="tertiary" onClick={induceBankInfoBeforeShare} style={{marginRight: '1rem'}}> - 카카오톡으로 정산 초대하기 - </Button> - ) : ( - <CopyToClipboard - text={shareText} - onCopy={() => - toast.confirm('링크가 복사되었어요 :) \n참여자들에게 링크를 공유해 주세요!', { - showingTime: 3000, - position: 'bottom', - }) - } - > - <Button size="small" variants="tertiary" onClick={induceBankInfoBeforeShare} style={{marginRight: '1rem'}}> - 정산 초대하기 - </Button> - </CopyToClipboard> - ); -}; - -export default ShareEventButton; diff --git a/client/src/components/ShareEventButton/index.ts b/client/src/components/ShareEventButton/index.ts index 27b5883aa..efadc061c 100644 --- a/client/src/components/ShareEventButton/index.ts +++ b/client/src/components/ShareEventButton/index.ts @@ -1 +1,2 @@ -export {default as ShareEventButton} from './ShareEventButton'; +export {default as DesktopShareEventButton} from './DesktopShareEventButton'; +export {default as MobileShareEventButton} from './MobileShareEventButton'; diff --git a/client/src/constants/routerUrls.ts b/client/src/constants/routerUrls.ts index 6be509841..802d509cd 100644 --- a/client/src/constants/routerUrls.ts +++ b/client/src/constants/routerUrls.ts @@ -2,7 +2,6 @@ export const ROUTER_URLS = { main: '/', createEvent: '/event/create', event: '/event', - eventLogin: '/event/:eventId/login', eventManage: '/event/:eventId/admin', home: '/event/:eventId/home', member: '/event/:eventId/admin/member', diff --git a/client/src/hooks/queries/auth/useRequestPostAuthentication.ts b/client/src/hooks/queries/auth/useRequestPostAuthentication.ts index b44cfcafc..12e1a6e0e 100644 --- a/client/src/hooks/queries/auth/useRequestPostAuthentication.ts +++ b/client/src/hooks/queries/auth/useRequestPostAuthentication.ts @@ -1,22 +1,18 @@ import {useMutation} from '@tanstack/react-query'; -import {useNavigate} from 'react-router-dom'; import {requestPostAuthentication} from '@apis/request/auth'; -import getEventIdByUrl from '@utils/getEventIdByUrl'; +import {useAuthStore} from '@store/authStore'; -import {ROUTER_URLS} from '@constants/routerUrls'; +import getEventIdByUrl from '@utils/getEventIdByUrl'; const useRequestPostAuthentication = () => { const eventId = getEventIdByUrl(); - const navigate = useNavigate(); + const {updateAuth} = useAuthStore(); const {mutate, ...rest} = useMutation({ mutationFn: () => requestPostAuthentication({eventId}), - onError: () => { - // 에러가 발생하면 로그인 페이지로 리다이렉트 - navigate(`${ROUTER_URLS.event}/${eventId}/login`); - }, + onSuccess: () => updateAuth(true), }); return { diff --git a/client/src/hooks/queries/auth/useRequestPostLogin.ts b/client/src/hooks/queries/auth/useRequestPostLogin.ts index f2f0d7c4c..203e1c60b 100644 --- a/client/src/hooks/queries/auth/useRequestPostLogin.ts +++ b/client/src/hooks/queries/auth/useRequestPostLogin.ts @@ -1,21 +1,18 @@ import {useMutation} from '@tanstack/react-query'; -import {useNavigate} from 'react-router-dom'; import {RequestPostToken, requestPostToken} from '@apis/request/auth'; -import getEventIdByUrl from '@utils/getEventIdByUrl'; +import {useAuthStore} from '@store/authStore'; -import {ROUTER_URLS} from '@constants/routerUrls'; +import getEventIdByUrl from '@utils/getEventIdByUrl'; const useRequestPostLogin = () => { const eventId = getEventIdByUrl(); - const navigate = useNavigate(); + const {updateAuth} = useAuthStore(); const {mutate, ...rest} = useMutation({ mutationFn: ({password}: RequestPostToken) => requestPostToken({eventId, password}), - onSuccess: () => { - navigate(`${ROUTER_URLS.event}/${eventId}/admin`); - }, + onSuccess: () => updateAuth(true), }); return {postLogin: mutate, ...rest}; diff --git a/client/src/hooks/useEventPageLayout.ts b/client/src/hooks/useEventPageLayout.ts index 57fea80d2..6e645d186 100644 --- a/client/src/hooks/useEventPageLayout.ts +++ b/client/src/hooks/useEventPageLayout.ts @@ -1,19 +1,15 @@ -import {useMatch} from 'react-router-dom'; +import {useAuthStore} from '@store/authStore'; import getEventIdByUrl from '@utils/getEventIdByUrl'; -import {ROUTER_URLS} from '@constants/routerUrls'; - import useRequestGetEvent from './queries/event/useRequestGetEvent'; const useEventPageLayout = () => { const eventId = getEventIdByUrl(); const {eventName, bankName, accountNumber} = useRequestGetEvent(); + const {isAdmin} = useAuthStore(); - const isAdmin = useMatch(ROUTER_URLS.eventManage) !== null; - const isLoginPage = useMatch(ROUTER_URLS.eventLogin) !== null; - - const eventOutline = { + const event = { eventName, bankName, accountNumber, @@ -22,8 +18,7 @@ const useEventPageLayout = () => { return { eventId, isAdmin, - isLoginPage, - eventOutline, + event, }; }; diff --git a/client/src/hooks/useShareEvent.ts b/client/src/hooks/useShareEvent.ts index 86d185f48..782a3539f 100644 --- a/client/src/hooks/useShareEvent.ts +++ b/client/src/hooks/useShareEvent.ts @@ -1,9 +1,26 @@ +import {useNavigate} from 'react-router-dom'; + +import {Event} from 'types/serviceType'; + +import {useAuthStore} from '@store/authStore'; + import getEventIdByUrl from '@utils/getEventIdByUrl'; import getEventPageUrlByEnvironment from '@utils/getEventPageUrlByEnvironment'; +import getDeletedLastPath from '@utils/getDeletedLastPath'; + +import toast from './useToast/toast'; + +type UserShareEventProps = { + event: Event; + isMobile: boolean; +}; -const useShareEvent = (eventName: string, isMobile: boolean) => { +const useShareEvent = ({event, isMobile}: UserShareEventProps) => { + const {eventName, bankName, accountNumber} = event; const eventId = getEventIdByUrl(); const url = getEventPageUrlByEnvironment(eventId, 'home'); + const navigate = useNavigate(); + const {isAdmin} = useAuthStore(); const shareInfo = { title: `[행동대장]\n${eventName}에 대한 정산을 시작할게요:)`, @@ -11,14 +28,6 @@ const useShareEvent = (eventName: string, isMobile: boolean) => { url, }; - // 모바일이 아닌 기기는 단순 텍스트 복사 - // 모바일 기기에서는 카카오톡 공유를 사용 - const onShareButtonClick = () => { - if (!isMobile) return; - - kakaoShare(); - }; - const kakaoShare = () => { window.Kakao.Share.sendDefault({ objectType: 'feed', @@ -38,6 +47,35 @@ const useShareEvent = (eventName: string, isMobile: boolean) => { const shareText = `${shareInfo.title}\n${shareInfo.text}\n${url}`; + const onShareButtonClick = () => { + const isReady = bankName !== '' && accountNumber !== ''; + + // induceBankInfoBeforeShare + if (!isReady && isAdmin) { + toast.error('잠깐! 정산을 초대하기 전에\n계좌를 등록해주세요', { + showingTime: 3000, + position: 'bottom', + }); + + const navigatePath = `${getDeletedLastPath(location.pathname)}/admin/edit`; + navigate(navigatePath); + return; + } + + if (!isReady && !isAdmin) { + toast.error('정산자가 계좌를 등록해야 초대 가능합니다.\n정산자에게 문의해주세요', { + showingTime: 3000, + position: 'bottom', + }); + return; + } + + // 모바일이 아닌 기기는 단순 텍스트 복사 + // 모바일 기기에서는 카카오톡 공유를 사용 + if (!isMobile) return; + kakaoShare(); + }; + return { shareText, onShareButtonClick, diff --git a/client/src/pages/EventPage/AdminPage/AdminPage.tsx b/client/src/pages/EventPage/AdminPage/AdminPage.tsx index c69170897..668732ced 100644 --- a/client/src/pages/EventPage/AdminPage/AdminPage.tsx +++ b/client/src/pages/EventPage/AdminPage/AdminPage.tsx @@ -1,8 +1,6 @@ -import {useEffect} from 'react'; import {useNavigate, useOutletContext} from 'react-router-dom'; import StepList from '@components/StepList/Steps'; -import useRequestPostAuthenticate from '@hooks/queries/auth/useRequestPostAuthentication'; import useRequestGetSteps from '@hooks/queries/step/useRequestGetSteps'; import {useTotalExpenseAmountStore} from '@store/totalExpenseAmountStore'; @@ -23,11 +21,6 @@ const AdminPage = () => { const {totalExpenseAmount} = useTotalExpenseAmountStore(); const {steps} = useRequestGetSteps(); - const {postAuthenticate} = useRequestPostAuthenticate(); - - useEffect(() => { - postAuthenticate(); - }, [postAuthenticate]); const navigateAccountInputPage = () => { navigate(`/event/${eventId}/admin/edit`); diff --git a/client/src/pages/EventPage/AuthGate/index.tsx b/client/src/pages/EventPage/AuthGate/index.tsx new file mode 100644 index 000000000..7031cc773 --- /dev/null +++ b/client/src/pages/EventPage/AuthGate/index.tsx @@ -0,0 +1,26 @@ +import {useEffect} from 'react'; + +import useRequestPostAuthentication from '@hooks/queries/auth/useRequestPostAuthentication'; + +import {useAuthStore} from '@store/authStore'; + +type AuthGateProps = React.PropsWithChildren & { + fallback: React.ReactNode; +}; + +const AuthGate = ({children, fallback}: AuthGateProps) => { + const {isError, postAuthenticate} = useRequestPostAuthentication(); + const {isAdmin} = useAuthStore(); + + useEffect(() => { + postAuthenticate(); + }, [postAuthenticate]); + + if (isError && !isAdmin) { + return fallback; + } + + return children; +}; + +export default AuthGate; diff --git a/client/src/pages/EventPage/EventPageLayout.tsx b/client/src/pages/EventPage/EventPageLayout.tsx index 2fcf0d682..dd28bc300 100644 --- a/client/src/pages/EventPage/EventPageLayout.tsx +++ b/client/src/pages/EventPage/EventPageLayout.tsx @@ -3,23 +3,29 @@ import type {Event} from 'types/serviceType'; import {Outlet} from 'react-router-dom'; import useEventPageLayout from '@hooks/useEventPageLayout'; +import useShareEvent from '@hooks/useShareEvent'; -import {ShareEventButton} from '@components/ShareEventButton'; +import {DesktopShareEventButton, MobileShareEventButton} from '@components/ShareEventButton'; import {Flex, Icon, IconButton, MainLayout, TopNav} from '@HDesign/index'; +import {isMobileDevice} from '@utils/detectDevice'; + export type EventPageContextProps = Event & { isAdmin: boolean; }; const EventPageLayout = () => { - const {isAdmin, isLoginPage, eventOutline} = useEventPageLayout(); - + const {isAdmin, event} = useEventPageLayout(); const outletContext: EventPageContextProps = { isAdmin, - ...eventOutline, + ...event, }; + const isMobile = isMobileDevice(); + + const {shareText, onShareButtonClick} = useShareEvent({event, isMobile}); + return ( <MainLayout backgroundColor="gray"> <Flex justifyContent="spaceBetween" alignItems="center"> @@ -32,7 +38,11 @@ const EventPageLayout = () => { <TopNav.Item displayName="홈" routePath="/home" /> <TopNav.Item displayName="관리" routePath="/admin" /> </TopNav> - {!isLoginPage && <ShareEventButton eventOutline={eventOutline} />} + {isMobile ? ( + <MobileShareEventButton text="카카오톡으로 초대하기" onClick={onShareButtonClick} /> + ) : ( + <DesktopShareEventButton text="정산 초대하기" shareText={shareText} onClick={onShareButtonClick} /> + )} </Flex> <Outlet context={outletContext} /> </MainLayout> diff --git a/client/src/router.tsx b/client/src/router.tsx index 81b4ad2cd..d0e376eb0 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -4,6 +4,7 @@ import {lazy, Suspense} from 'react'; import ErrorPage from '@pages/ErrorPage/ErrorPage'; import EventLoginPage from '@pages/EventPage/AdminPage/EventLoginPage'; import EventLoader from '@components/Loader/EventLoader'; +import AuthGate from '@pages/EventPage/AuthGate'; import {EventPage} from '@pages/EventPage'; @@ -49,13 +50,13 @@ const router = createBrowserRouter([ children: [ { path: ROUTER_URLS.eventManage, - element: <AdminPage />, + element: ( + <AuthGate fallback={<EventLoginPage />}> + <AdminPage /> + </AuthGate> + ), }, {path: ROUTER_URLS.home, element: <HomePage />}, - { - path: ROUTER_URLS.eventLogin, - element: <EventLoginPage />, - }, ], }, { diff --git a/client/src/store/authStore.ts b/client/src/store/authStore.ts new file mode 100644 index 000000000..2b95eeb92 --- /dev/null +++ b/client/src/store/authStore.ts @@ -0,0 +1,14 @@ +import {create} from 'zustand'; + +type State = { + isAdmin: boolean; +}; + +type Action = { + updateAuth: (isAdmin: boolean) => void; +}; + +export const useAuthStore = create<State & Action>(set => ({ + isAdmin: false, + updateAuth: isAdmin => set(() => ({isAdmin})), +})); From dd39b893405a747a15e5e44a3e86a392d674197d Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:02:36 +0900 Subject: [PATCH 08/20] =?UTF-8?q?feat:=20=EC=A0=95=EC=82=B0=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=20=EB=B0=8F=20=EC=86=A1=EA=B8=88=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=83=80=EA=B2=9F=20=ED=99=98=EA=B2=BD=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20flow=20=EA=B0=9C=EC=84=A0=20(#701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 공유버튼 모바일과 데탑 분리 * feat: 송금버튼 새로운 반영사항 적용 * feat: 계좌번호 없을 때 금액 복사 기능 추가 * test: prop 변경으로 인한 스토리북 변경 * feat: 계좌번호 없을 때 금액 복사되는 onCopy 추가 * feat: 다나까 -> 요 체로 변경 * feat: 송금기능 페이지 이동으로 인해 memberId 추가 * feat: 송금 페이지 라우트 * feat: 송금하기 버튼을 눌렀을 때 navigate state로 정보 전달 * feat: 송금 방법(복사, 카카오페이, 토스) 제공 * feat: 아래 => 토스열기 명시적으로 변경 * feat: Banner 컴포넌트 생성 * feat: 세션스토리지 util 추가 * feat: 계좌번호 입력 유도 기능 구현 * feat: Flex div prop도 받도록 설정 * feat: 돌아가는 Chevron 컴포넌트 생성 * feat: 바깥 클릭 시 실행되는 컴포넌트 생성 * feat: Select 컴포넌트 제작 * feat: 요구사항 변경으로 인한 송금 플로우 변경 * feat: Select 컴포넌트 제작 * design: 텍스트 색깔 변경 * style: export 정리 * feat: 이벤트 별로 배너 상태 다르게 적용되도록 event token 인자 추가 * refactor: admin page 기능들 훅으로 분리 --- .../BankSendButton/BankSendButton.style.ts | 23 ---- .../BankSendButton/BankSendButton.tsx | 38 ------- .../components/Banner/Banner.stories.tsx | 26 +++++ .../Design/components/Banner/Banner.tsx | 43 ++++++++ .../Design/components/Banner/Banner.type.ts | 6 ++ .../Design/components/Banner/index.ts | 1 + .../components/Chevron/Chevron.style.ts | 9 ++ .../Design/components/Chevron/Chevron.tsx | 25 +++++ .../components/ClickOutsideDetector/index.tsx | 32 ++++++ .../ExpenseList/ExpenseList.stories.tsx | 16 ++- .../components/ExpenseList/ExpenseList.tsx | 34 +++--- .../ExpenseList/ExpenseList.type.ts | 6 +- .../Design/components/Flex/Flex.tsx | 42 +++++++- .../Design/components/Flex/Flex.type.ts | 4 +- .../components/Select/Select.stories.tsx | 37 +++++++ .../Design/components/Select/Select.style.ts | 45 ++++++++ .../Design/components/Select/Select.tsx | 51 +++++++++ .../Design/components/Select/Select.type.ts | 12 +++ .../components/Select/SelectInput.style.ts | 69 ++++++++++++ .../Design/components/Select/SelectInput.tsx | 59 +++++++++++ .../Design/components/Select/index.ts | 1 + .../Design/components/Select/useSelect.ts | 29 +++++ .../SendButton.stories.tsx} | 6 +- .../components/SendButton/SendButton.style.ts | 13 +++ .../components/SendButton/SendButton.tsx | 28 +++++ client/src/components/Design/index.tsx | 2 + client/src/components/Design/token/zIndex.ts | 5 +- client/src/constants/routerUrls.ts | 1 + client/src/constants/sessionStorageKeys.ts | 5 + client/src/hooks/useAdminPage.ts | 55 ++++++++++ client/src/hooks/useReportsPage.ts | 48 ++++++--- client/src/hooks/useSendPage.ts | 100 ++++++++++++++++++ client/src/hooks/useShareEvent.ts | 2 +- .../pages/ErrorPage/SendErrorPage/index.tsx | 24 +++++ .../pages/EventPage/AdminPage/AdminPage.tsx | 33 +++--- client/src/pages/SendPage/index.tsx | 36 +++++++ client/src/router.tsx | 7 ++ client/src/utils/SessionStorage.ts | 19 ++++ 38 files changed, 872 insertions(+), 120 deletions(-) delete mode 100644 client/src/components/Design/components/BankSendButton/BankSendButton.style.ts delete mode 100644 client/src/components/Design/components/BankSendButton/BankSendButton.tsx create mode 100644 client/src/components/Design/components/Banner/Banner.stories.tsx create mode 100644 client/src/components/Design/components/Banner/Banner.tsx create mode 100644 client/src/components/Design/components/Banner/Banner.type.ts create mode 100644 client/src/components/Design/components/Banner/index.ts create mode 100644 client/src/components/Design/components/Chevron/Chevron.style.ts create mode 100644 client/src/components/Design/components/Chevron/Chevron.tsx create mode 100644 client/src/components/Design/components/ClickOutsideDetector/index.tsx create mode 100644 client/src/components/Design/components/Select/Select.stories.tsx create mode 100644 client/src/components/Design/components/Select/Select.style.ts create mode 100644 client/src/components/Design/components/Select/Select.tsx create mode 100644 client/src/components/Design/components/Select/Select.type.ts create mode 100644 client/src/components/Design/components/Select/SelectInput.style.ts create mode 100644 client/src/components/Design/components/Select/SelectInput.tsx create mode 100644 client/src/components/Design/components/Select/index.ts create mode 100644 client/src/components/Design/components/Select/useSelect.ts rename client/src/components/Design/components/{BankSendButton/BankSend.stories.tsx => SendButton/SendButton.stories.tsx} (79%) create mode 100644 client/src/components/Design/components/SendButton/SendButton.style.ts create mode 100644 client/src/components/Design/components/SendButton/SendButton.tsx create mode 100644 client/src/constants/sessionStorageKeys.ts create mode 100644 client/src/hooks/useAdminPage.ts create mode 100644 client/src/hooks/useSendPage.ts create mode 100644 client/src/pages/ErrorPage/SendErrorPage/index.tsx create mode 100644 client/src/pages/SendPage/index.tsx create mode 100644 client/src/utils/SessionStorage.ts diff --git a/client/src/components/Design/components/BankSendButton/BankSendButton.style.ts b/client/src/components/Design/components/BankSendButton/BankSendButton.style.ts deleted file mode 100644 index bb6bbffb7..000000000 --- a/client/src/components/Design/components/BankSendButton/BankSendButton.style.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {css} from '@emotion/react'; - -import {Theme} from '@components/Design/theme/theme.type'; - -export const bankButtonStyle = (theme: Theme) => - css({ - width: '3.25rem', - height: '1.5rem', - - backgroundColor: theme.colors.tertiary, - - borderRadius: '0.5rem', - }); - -export const isDepositedStyle = (theme: Theme) => - css({ - width: '3.25rem', - height: '1.5rem', - - backgroundColor: theme.colors.grayContainer, - - borderRadius: '0.5rem', - }); diff --git a/client/src/components/Design/components/BankSendButton/BankSendButton.tsx b/client/src/components/Design/components/BankSendButton/BankSendButton.tsx deleted file mode 100644 index 893b7cf3d..000000000 --- a/client/src/components/Design/components/BankSendButton/BankSendButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** @jsxImportSource @emotion/react */ -import {useTheme} from '@components/Design/theme/HDesignProvider'; - -import Icon from '../Icon/Icon'; -import Text from '../Text/Text'; -import Flex from '../Flex/Flex'; - -import {bankButtonStyle, isDepositedStyle} from './BankSendButton.style'; - -type BankSendButtonProps = React.HTMLAttributes<HTMLButtonElement> & { - onBankButtonClick: () => void; - isDeposited?: boolean; -}; - -const BankSendButton = ({onBankButtonClick, isDeposited = false, ...buttonProps}: BankSendButtonProps) => { - const {theme} = useTheme(); - - return isDeposited ? ( - <button css={isDepositedStyle(theme)} disabled {...buttonProps}> - <Flex justifyContent="center" alignItems="center"> - <Text size="tiny" textColor="black"> - 송금완료 - </Text> - </Flex> - </button> - ) : ( - <button onClick={onBankButtonClick} css={bankButtonStyle(theme)} {...buttonProps}> - <Flex justifyContent="center" alignItems="center" gap="0.125rem"> - <Text size="tiny" textColor="black"> - 송금 - </Text> - <Icon iconType="toss" /> - </Flex> - </button> - ); -}; - -export default BankSendButton; diff --git a/client/src/components/Design/components/Banner/Banner.stories.tsx b/client/src/components/Design/components/Banner/Banner.stories.tsx new file mode 100644 index 000000000..fc726df10 --- /dev/null +++ b/client/src/components/Design/components/Banner/Banner.stories.tsx @@ -0,0 +1,26 @@ +/** @jsxImportSource @emotion/react */ +import type {Meta, StoryObj} from '@storybook/react'; + +import Banner from './Banner'; + +const meta = { + title: 'Components/Banner', + component: Banner, + tags: ['autodocs'], + parameters: { + // layout: 'centered', + }, + argTypes: {}, + args: { + onDelete: () => console.log(''), + title: '계좌번호가 등록되지 않았어요', + description: '계좌번호를 입력해야 참여자가 편하게 송금할 수 있어요', + buttonText: '등록하기', + }, +} satisfies Meta<typeof Banner>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Playground: Story = {}; diff --git a/client/src/components/Design/components/Banner/Banner.tsx b/client/src/components/Design/components/Banner/Banner.tsx new file mode 100644 index 000000000..9cb2ddca2 --- /dev/null +++ b/client/src/components/Design/components/Banner/Banner.tsx @@ -0,0 +1,43 @@ +/** @jsxImportSource @emotion/react */ +import type {BannerProps} from './Banner.type'; + +import Flex from '../Flex/Flex'; +import Icon from '../Icon/Icon'; +import IconButton from '../IconButton/IconButton'; +import Text from '../Text/Text'; +import Button from '../Button/Button'; + +const Banner = ({title, description, buttonText, onDelete, ...buttonProps}: BannerProps) => { + return ( + <Flex + justifyContent="spaceBetween" + alignItems="center" + width="100%" + padding="0.5rem" + paddingInline="0.5rem" + backgroundColor="white" + otherStyle={{borderRadius: '0.75rem'}} + > + <Flex gap="0.5rem"> + <IconButton variants="none" onClick={onDelete} style={{display: 'flex', alignItems: 'flex-start'}}> + <Icon iconType="x" /> + </IconButton> + <div> + <Text size="captionBold" color="onTertiary"> + {title} + </Text> + {description && ( + <Text size="tiny" color="onTertiary"> + {description} + </Text> + )} + </div> + </Flex> + <Button variants="tertiary" size="small" {...buttonProps}> + {buttonText} + </Button> + </Flex> + ); +}; + +export default Banner; diff --git a/client/src/components/Design/components/Banner/Banner.type.ts b/client/src/components/Design/components/Banner/Banner.type.ts new file mode 100644 index 000000000..3dcdbc0f4 --- /dev/null +++ b/client/src/components/Design/components/Banner/Banner.type.ts @@ -0,0 +1,6 @@ +export type BannerProps = React.HTMLAttributes<HTMLButtonElement> & { + onDelete: () => void; + title: string; + description?: string; + buttonText: string; +}; diff --git a/client/src/components/Design/components/Banner/index.ts b/client/src/components/Design/components/Banner/index.ts new file mode 100644 index 000000000..f7e7e8f2d --- /dev/null +++ b/client/src/components/Design/components/Banner/index.ts @@ -0,0 +1 @@ +export {default as Banner} from './Banner'; diff --git a/client/src/components/Design/components/Chevron/Chevron.style.ts b/client/src/components/Design/components/Chevron/Chevron.style.ts new file mode 100644 index 000000000..14b67e80e --- /dev/null +++ b/client/src/components/Design/components/Chevron/Chevron.style.ts @@ -0,0 +1,9 @@ +import {css} from '@emotion/react'; + +export const chevronStyle = css({ + transition: 'transform 0.3s ease', +}); + +export const activeChevronStyle = css({ + transform: 'rotate(180deg)', +}); diff --git a/client/src/components/Design/components/Chevron/Chevron.tsx b/client/src/components/Design/components/Chevron/Chevron.tsx new file mode 100644 index 000000000..9e5ac4947 --- /dev/null +++ b/client/src/components/Design/components/Chevron/Chevron.tsx @@ -0,0 +1,25 @@ +/** @jsxImportSource @emotion/react */ +import {chevronStyle, activeChevronStyle} from './Chevron.style'; + +type ChevronProps = { + isActive: boolean; +}; + +const Chevron = ({isActive}: ChevronProps) => { + return ( + <div> + <svg + width="20" + height="20" + viewBox="0 0 20 20" + fill="none" + xmlns="http://www.w3.org/2000/svg" + css={[chevronStyle, isActive && activeChevronStyle]} + > + <path d="M4 7L10 13L16 7" stroke="#B2B1B6" strokeWidth="1.5" strokeLinecap="round" /> + </svg> + </div> + ); +}; + +export default Chevron; diff --git a/client/src/components/Design/components/ClickOutsideDetector/index.tsx b/client/src/components/Design/components/ClickOutsideDetector/index.tsx new file mode 100644 index 000000000..8eaf4da5b --- /dev/null +++ b/client/src/components/Design/components/ClickOutsideDetector/index.tsx @@ -0,0 +1,32 @@ +import {useEffect} from 'react'; + +type ClickOutsideDetectorProps<T extends HTMLElement> = React.PropsWithChildren & { + targetRef: React.RefObject<T>; + onClickOutside: () => void; +}; + +const ClickOutsideDetector = <T extends HTMLElement>({ + targetRef, + onClickOutside, + children, +}: ClickOutsideDetectorProps<T>) => { + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const targetNode = event.target as Node; + + if (targetRef.current && !targetRef.current.contains(targetNode)) { + onClickOutside(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [targetRef]); + + return children; +}; + +export default ClickOutsideDetector; diff --git a/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx b/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx index 830e84206..d757c3a7c 100644 --- a/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx +++ b/client/src/components/Design/components/ExpenseList/ExpenseList.stories.tsx @@ -23,28 +23,36 @@ const meta = { memberName: '소하', price: 2000, isDeposited: true, - onBankButtonClick: (amount: number) => console.log(amount), + canSendBank: true, + onSendButtonClick: (memberId: number, amount: number) => console.log(amount), + onCopy: async (amount: number) => console.log(amount), }, { memberId: 2, memberName: '토다리', price: 2000, isDeposited: false, - onBankButtonClick: (amount: number) => console.log(amount), + canSendBank: true, + onSendButtonClick: (memberId: number, amount: number) => console.log(amount), + onCopy: async (amount: number) => console.log(amount), }, { memberId: 3, memberName: '웨디', price: 1080, isDeposited: true, - onBankButtonClick: (amount: number) => console.log(amount), + canSendBank: true, + onSendButtonClick: (memberId: number, amount: number) => console.log(amount), + onCopy: async (amount: number) => console.log(amount), }, { memberId: 4, memberName: '쿠키', price: 3020, isDeposited: false, - onBankButtonClick: (amount: number) => console.log(amount), + canSendBank: true, + onSendButtonClick: (memberId: number, amount: number) => console.log(amount), + onCopy: async (amount: number) => console.log(amount), }, ], }, diff --git a/client/src/components/Design/components/ExpenseList/ExpenseList.tsx b/client/src/components/Design/components/ExpenseList/ExpenseList.tsx index 23f6e2fdf..8ccaa7ef0 100644 --- a/client/src/components/Design/components/ExpenseList/ExpenseList.tsx +++ b/client/src/components/Design/components/ExpenseList/ExpenseList.tsx @@ -1,19 +1,33 @@ /** @jsxImportSource @emotion/react */ import Text from '@HDcomponents/Text/Text'; -import {isMobileDevice} from '@utils/detectDevice'; - -import BankSendButton from '../BankSendButton/BankSendButton'; -import Icon from '../Icon/Icon'; -import IconButton from '../IconButton/IconButton'; import Flex from '../Flex/Flex'; import Input from '../Input/Input'; import Amount from '../Amount/Amount'; import DepositCheck from '../DepositCheck/DepositCheck'; +import SendButton from '../SendButton/SendButton'; import {ExpenseItemProps, ExpenseListProps} from './ExpenseList.type'; -function ExpenseItem({memberName, price, isDeposited, onBankButtonClick, ...divProps}: ExpenseItemProps) { +function ExpenseItem({ + memberId, + memberName, + price, + isDeposited, + canSendBank, + onSendButtonClick, + onCopy, + ...divProps +}: ExpenseItemProps) { + const onClick = () => { + // 송금 가능하면 송금페이지, 아니라면 금액복사 + if (canSendBank) { + onSendButtonClick(memberId, price); + } else { + onCopy(price); + } + }; + return ( <Flex justifyContent="spaceBetween" @@ -31,13 +45,7 @@ function ExpenseItem({memberName, price, isDeposited, onBankButtonClick, ...divP </Flex> <Flex alignItems="center" gap="0.5rem"> <Amount amount={price} /> - {isMobileDevice() ? ( - <BankSendButton onBankButtonClick={() => onBankButtonClick(price)} isDeposited={price <= 0 || isDeposited} /> - ) : ( - <IconButton variants="none" size="small"> - <Icon iconType="rightChevron" /> - </IconButton> - )} + <SendButton onClick={onClick} isDeposited={price <= 0 || isDeposited} canSend={canSendBank} /> </Flex> </Flex> ); diff --git a/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts b/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts index 7ac189579..3551f88d6 100644 --- a/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts +++ b/client/src/components/Design/components/ExpenseList/ExpenseList.type.ts @@ -1,10 +1,12 @@ import {Report} from 'types/serviceType'; export type ExpenseItemCustomProps = Report & { - onBankButtonClick: (amount: number) => void; + onSendButtonClick: (memberId: number, amount: number) => void; + onCopy: (amount: number) => Promise<void>; + canSendBank: boolean; }; -export type ExpenseItemProps = React.ComponentProps<'div'> & ExpenseItemCustomProps; +export type ExpenseItemProps = Omit<React.ComponentProps<'div'>, 'onCopy'> & ExpenseItemCustomProps; export type ExpenseListProps = { memberName: string; diff --git a/client/src/components/Design/components/Flex/Flex.tsx b/client/src/components/Design/components/Flex/Flex.tsx index d114a90d7..51341dccf 100644 --- a/client/src/components/Design/components/Flex/Flex.tsx +++ b/client/src/components/Design/components/Flex/Flex.tsx @@ -1,4 +1,6 @@ /** @jsxImportSource @emotion/react */ +import {forwardRef} from 'react'; + import {StrictPropsWithChildren} from '@type/strictPropsWithChildren'; import {useTheme} from '../../index'; @@ -7,13 +9,47 @@ import {FlexProps} from './Flex.type'; import {flexStyle} from './Flex.style'; // TODO: (@weadie) 지정된 프롭 말고 다른 프롭도 가져올 수 있게 하자. -function Flex({children, otherStyle, ...props}: StrictPropsWithChildren<FlexProps>) { +const Flex = forwardRef<HTMLDivElement, StrictPropsWithChildren<FlexProps>>(({children, otherStyle, ...props}, ref) => { const {theme} = useTheme(); + + const { + justifyContent, + alignItems, + flexDirection, + gap, + padding, + paddingInline, + margin, + width, + height, + backgroundColor, + minHeight, + ...htmlProps + } = props; + return ( - <div css={flexStyle({theme, ...props})} style={otherStyle}> + <div + ref={ref} + css={flexStyle({ + theme, + justifyContent, + alignItems, + flexDirection, + gap, + padding, + paddingInline, + margin, + width, + height, + backgroundColor, + minHeight, + })} + style={{...otherStyle}} + {...htmlProps} + > {children} </div> ); -} +}); export default Flex; diff --git a/client/src/components/Design/components/Flex/Flex.type.ts b/client/src/components/Design/components/Flex/Flex.type.ts index 56ab47d2a..e251db35c 100644 --- a/client/src/components/Design/components/Flex/Flex.type.ts +++ b/client/src/components/Design/components/Flex/Flex.type.ts @@ -4,7 +4,7 @@ export type FlexDirectionType = 'row' | 'column' | 'rowReverse' | 'columnReverse export type FlexDirectionStrictType = 'row' | 'column' | 'row-reverse' | 'column-reverse'; export type FlexBackgroundColor = 'gray' | 'white' | 'lightGray'; -export interface FlexProps { +export type FlexProps = React.HTMLAttributes<HTMLDivElement> & { justifyContent?: 'flexStart' | 'center' | 'flexEnd' | 'spaceBetween' | 'spaceAround' | 'spaceEvenly'; alignItems?: 'flexStart' | 'center' | 'flexEnd' | 'stretch' | 'baseline'; flexDirection?: FlexDirectionType; @@ -19,4 +19,4 @@ export interface FlexProps { minHeight?: string; otherStyle?: React.CSSProperties; -} +}; diff --git a/client/src/components/Design/components/Select/Select.stories.tsx b/client/src/components/Design/components/Select/Select.stories.tsx new file mode 100644 index 000000000..fbf6688c4 --- /dev/null +++ b/client/src/components/Design/components/Select/Select.stories.tsx @@ -0,0 +1,37 @@ +/** @jsxImportSource @emotion/react */ +import type {Meta, StoryObj} from '@storybook/react'; + +import Select from './Select'; + +// Option 타입 정의 +type Option = '쿠키' | '토다리' | '웨디' | '소하'; +const options: Option[] = ['쿠키', '토다리', '웨디', '소하']; + +const meta: Meta<typeof Select<Option>> = { + title: 'Components/Select', + component: Select, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + decorators: [ + Story => ( + <div style={{width: '400px', height: '400px', backgroundColor: 'white', padding: '1rem'}}> + <Story /> + </div> + ), + ], + args: { + labelText: '송금 방법 선택', + placeholder: '송금 방법 선택', + options: options, + onSelect: (option: Option) => console.log(option), + }, +}; + +export default meta; + +type Story = StoryObj<typeof meta>; + +// Playground 스토리 +export const Playground: Story = {}; diff --git a/client/src/components/Design/components/Select/Select.style.ts b/client/src/components/Design/components/Select/Select.style.ts new file mode 100644 index 000000000..0a127cf1e --- /dev/null +++ b/client/src/components/Design/components/Select/Select.style.ts @@ -0,0 +1,45 @@ +import {css} from '@emotion/react'; + +import {Theme} from '@theme/theme.type'; + +export const selectStyle = css({ + position: 'relative', + + width: '100%', +}); + +export const optionListStyle = (theme: Theme, isOpen: boolean) => + css({ + position: 'absolute', + top: '5.5rem', + zIndex: theme.zIndex.selectOption, + + width: '100%', + padding: '0.5rem', + + borderRadius: '1rem', + boxShadow: `0 0 0 1px ${theme.colors.primary} inset`, + + backgroundColor: theme.colors.white, + + visibility: isOpen ? 'visible' : 'hidden', + opacity: isOpen ? 1 : 0, + transition: 'opacity 0.2s cubic-bezier(0.7, 0.62, 0.62, 1.16), transform 0.2s cubic-bezier(0.7, 0.62, 0.62, 1.16)', + }); + +export const optionStyle = (theme: Theme) => + css( + { + width: '100%', + padding: '0.5rem', + + color: theme.colors.onTertiary, + + '&:hover': { + borderRadius: '0.5rem', + + backgroundColor: theme.colors.lightGrayContainer, + }, + }, + theme.typography.body, + ); diff --git a/client/src/components/Design/components/Select/Select.tsx b/client/src/components/Design/components/Select/Select.tsx new file mode 100644 index 000000000..3f19d4e7d --- /dev/null +++ b/client/src/components/Design/components/Select/Select.tsx @@ -0,0 +1,51 @@ +/** @jsxImportSource @emotion/react */ +import {useTheme} from '@theme/HDesignProvider'; + +import Flex from '../Flex/Flex'; +import ClickOutsideDetector from '../ClickOutsideDetector'; + +import {selectStyle, optionListStyle, optionStyle} from './Select.style'; +import useSelect from './useSelect'; +import {SelectProps} from './Select.type'; +import SelectInput from './SelectInput'; + +const Select = <T extends string | number | readonly string[]>({ + labelText, + placeholder, + defaultValue, + options, + onSelect, +}: SelectProps<T>) => { + const {theme} = useTheme(); + const {selectRef, isOpen, value, handleSelect, setIsOpen} = useSelect({defaultValue, onSelect}); + + return ( + <ClickOutsideDetector targetRef={selectRef} onClickOutside={() => setIsOpen(false)}> + <fieldset css={selectStyle}> + <SelectInput + labelText={labelText} + placeholder={placeholder ?? ''} + value={value} + readOnly + hasFocus={isOpen} + setHasFocus={setIsOpen} + /> + {options.length > 0 && ( + <ul ref={selectRef} css={optionListStyle(theme, isOpen)}> + <Flex flexDirection="column" gap="0.5rem"> + {options.map((option, index) => ( + <li key={`${option}-${index}`}> + <button type="button" css={optionStyle(theme)} onClick={() => handleSelect(option)}> + {option} + </button> + </li> + ))} + </Flex> + </ul> + )} + </fieldset> + </ClickOutsideDetector> + ); +}; + +export default Select; diff --git a/client/src/components/Design/components/Select/Select.type.ts b/client/src/components/Design/components/Select/Select.type.ts new file mode 100644 index 000000000..6f0f23d35 --- /dev/null +++ b/client/src/components/Design/components/Select/Select.type.ts @@ -0,0 +1,12 @@ +export type SelectInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onSelect'> & { + labelText?: string; + placeholder?: string; + hasFocus?: boolean; + setHasFocus?: React.Dispatch<React.SetStateAction<boolean>>; +}; + +export type SelectProps<T> = SelectInputProps & { + defaultValue?: T; + options: T[]; + onSelect: (option: T) => void; +}; diff --git a/client/src/components/Design/components/Select/SelectInput.style.ts b/client/src/components/Design/components/Select/SelectInput.style.ts new file mode 100644 index 000000000..589a7a5c4 --- /dev/null +++ b/client/src/components/Design/components/Select/SelectInput.style.ts @@ -0,0 +1,69 @@ +import {css} from '@emotion/react'; + +import {Theme} from '@theme/theme.type'; + +const getBorderStyle = (isFocus: boolean, theme: Theme) => + isFocus ? `0 0 0 1px ${theme.colors.primary} inset` : 'none'; + +export const labelTextStyle = (theme: Theme, hasFocus: boolean, hasValue: boolean) => + css([ + { + height: '1.125rem', + color: theme.colors.gray, + }, + labelTextAnimationStyle(hasFocus, hasValue), + ]); + +export const labelTextAnimationStyle = (hasFocus: boolean, hasValue: boolean) => + css({ + opacity: hasFocus || hasValue ? '1' : '0', + + transition: '0.2s', + transitionTimingFunction: 'cubic-bezier(0.7, 0.62, 0.62, 1.16)', + }); + +export const errorTextStyle = (theme: Theme, isError: boolean) => + css({ + height: '1.125rem', + color: theme.colors.onErrorContainer, + + opacity: isError ? '1' : '0', + + transition: '0.2s', + transitionTimingFunction: 'cubic-bezier(0.7, 0.62, 0.62, 1.16)', + }); + +export const inputBoxStyle = (theme: Theme, isFocus: boolean) => + css([ + { + display: 'flex', + justifyContent: 'space-between', + gap: '1rem', + padding: '0.75rem 1rem', + borderRadius: '1rem', + backgroundColor: theme.colors.lightGrayContainer, + boxSizing: 'border-box', + boxShadow: getBorderStyle(isFocus, theme), + }, + inputBoxAnimationStyle(), + ]); + +export const inputBoxAnimationStyle = () => + css({ + transition: '0.2s', + transitionTimingFunction: 'cubic-bezier(0.7, 0.62, 0.62, 1.16)', + }); + +export const inputStyle = (theme: Theme) => + css( + { + display: 'flex', + width: '100%', + color: theme.colors.black, + + '&:placeholder': { + color: theme.colors.gray, + }, + }, + theme.typography.body, + ); diff --git a/client/src/components/Design/components/Select/SelectInput.tsx b/client/src/components/Design/components/Select/SelectInput.tsx new file mode 100644 index 000000000..b2b4228ee --- /dev/null +++ b/client/src/components/Design/components/Select/SelectInput.tsx @@ -0,0 +1,59 @@ +/** @jsxImportSource @emotion/react */ +import {useState} from 'react'; + +import {useTheme} from '@components/Design/theme/HDesignProvider'; + +import Flex from '../Flex/Flex'; +import Text from '../Text/Text'; +import IconButton from '../IconButton/IconButton'; +import Chevron from '../Chevron/Chevron'; + +import {SelectInputProps} from './Select.type'; +import {inputBoxStyle, inputStyle, labelTextStyle} from './SelectInput.style'; + +const SelectInput = ({ + labelText, + placeholder, + value, + hasFocus = false, + setHasFocus, + onChange, + ...inputProps +}: SelectInputProps) => { + const {theme} = useTheme(); + const hasValue = !!value; + + const onFocusChange = () => { + if (setHasFocus) setHasFocus(prev => !prev); + }; + + return ( + <Flex flexDirection="column" gap="0.375rem"> + {labelText && ( + <Flex justifyContent="spaceBetween" paddingInline="0.5rem" margin="0 0 0.375rem 0"> + {labelText && ( + <Text size="caption" css={labelTextStyle(theme, hasFocus, hasValue)}> + {labelText} + </Text> + )} + </Flex> + )} + <Flex flexDirection="column" gap="0.5rem" onClick={onFocusChange}> + <div css={inputBoxStyle(theme, hasFocus)}> + <input + css={inputStyle(theme)} + value={value} + onChange={onChange} + placeholder={value ? '' : placeholder} + {...inputProps} + /> + <IconButton variants="none"> + <Chevron isActive={hasFocus} /> + </IconButton> + </div> + </Flex> + </Flex> + ); +}; + +export default SelectInput; diff --git a/client/src/components/Design/components/Select/index.ts b/client/src/components/Design/components/Select/index.ts new file mode 100644 index 000000000..acde7daa3 --- /dev/null +++ b/client/src/components/Design/components/Select/index.ts @@ -0,0 +1 @@ +export {default as Select} from './Select'; diff --git a/client/src/components/Design/components/Select/useSelect.ts b/client/src/components/Design/components/Select/useSelect.ts new file mode 100644 index 000000000..d0ca0ff84 --- /dev/null +++ b/client/src/components/Design/components/Select/useSelect.ts @@ -0,0 +1,29 @@ +import {useRef, useState} from 'react'; + +type UseSelectProps<T> = { + defaultValue?: T; + onSelect: (option: T) => void; +}; + +const useSelect = <T extends string | number | readonly string[]>({defaultValue, onSelect}: UseSelectProps<T>) => { + const [isOpen, setIsOpen] = useState(false); + const [value, setValue] = useState(defaultValue); + + const selectRef = useRef<HTMLUListElement>(null); + + const handleSelect = (option: T) => { + setValue(option); + onSelect(option); + setIsOpen(false); + }; + + return { + selectRef, + isOpen, + value, + handleSelect, + setIsOpen, + }; +}; + +export default useSelect; diff --git a/client/src/components/Design/components/BankSendButton/BankSend.stories.tsx b/client/src/components/Design/components/SendButton/SendButton.stories.tsx similarity index 79% rename from client/src/components/Design/components/BankSendButton/BankSend.stories.tsx rename to client/src/components/Design/components/SendButton/SendButton.stories.tsx index 153f890d0..921c7c70b 100644 --- a/client/src/components/Design/components/BankSendButton/BankSend.stories.tsx +++ b/client/src/components/Design/components/SendButton/SendButton.stories.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @emotion/react */ import type {Meta, StoryObj} from '@storybook/react'; -import BankSendButton from './BankSendButton'; +import BankSendButton from './SendButton'; const meta = { title: 'Components/BankSendButton', @@ -17,7 +17,9 @@ const meta = { }, }, args: { - onBankButtonClick: () => console.log('안녕'), + isDeposited: false, + canSend: true, + onClick: () => console.log('안녕'), }, } satisfies Meta<typeof BankSendButton>; diff --git a/client/src/components/Design/components/SendButton/SendButton.style.ts b/client/src/components/Design/components/SendButton/SendButton.style.ts new file mode 100644 index 000000000..128346893 --- /dev/null +++ b/client/src/components/Design/components/SendButton/SendButton.style.ts @@ -0,0 +1,13 @@ +import {css} from '@emotion/react'; + +import {Theme} from '@components/Design/theme/theme.type'; + +export const sendButtonStyle = (theme: Theme, disabled: boolean) => + css({ + width: '3.25rem', + height: '1.5rem', + + backgroundColor: disabled ? theme.colors.grayContainer : theme.colors.tertiary, + + borderRadius: '0.5rem', + }); diff --git a/client/src/components/Design/components/SendButton/SendButton.tsx b/client/src/components/Design/components/SendButton/SendButton.tsx new file mode 100644 index 000000000..d94b93658 --- /dev/null +++ b/client/src/components/Design/components/SendButton/SendButton.tsx @@ -0,0 +1,28 @@ +/** @jsxImportSource @emotion/react */ +import {useTheme} from '@components/Design/theme/HDesignProvider'; + +import Text from '../Text/Text'; +import Flex from '../Flex/Flex'; + +import {sendButtonStyle} from './SendButton.style'; + +type BankSendButtonProps = React.HTMLAttributes<HTMLButtonElement> & { + isDeposited: boolean; + canSend: boolean; +}; + +const SendButton = ({isDeposited = false, canSend = true, ...buttonProps}: BankSendButtonProps) => { + const {theme} = useTheme(); + + return ( + <button css={sendButtonStyle(theme, isDeposited)} disabled={isDeposited} {...buttonProps}> + <Flex justifyContent="center" alignItems="center"> + <Text size="tiny" textColor="black"> + {canSend ? (isDeposited ? '송금완료' : '송금하기') : '금액복사'} + </Text> + </Flex> + </button> + ); +}; + +export default SendButton; diff --git a/client/src/components/Design/index.tsx b/client/src/components/Design/index.tsx index 756f63b65..4440029b8 100644 --- a/client/src/components/Design/index.tsx +++ b/client/src/components/Design/index.tsx @@ -26,6 +26,7 @@ import DepositToggle from './components/DepositToggle/DepositToggle'; import Amount from './components/Amount/Amount'; import Dropdown from './components/Dropdown/Dropdown'; import DropdownButton from './components/Dropdown/DropdownButton'; +import {Select} from './components/Select'; export { HDesignProvider, @@ -57,4 +58,5 @@ export { Dropdown, DropdownButton, useTheme, + Select, }; diff --git a/client/src/components/Design/token/zIndex.ts b/client/src/components/Design/token/zIndex.ts index 60ec5a6cd..9ed1ef818 100644 --- a/client/src/components/Design/token/zIndex.ts +++ b/client/src/components/Design/token/zIndex.ts @@ -12,6 +12,7 @@ const NUMBER_KEYBOARD_BOTTOM_SHEET = FIXED_BUTTON + ABOVE; const BOTTOM_SHEET_DIMMED_LAYER = NUMBER_KEYBOARD_BOTTOM_SHEET + ABOVE; const BOTTOM_SHEET_CONTAINER = BOTTOM_SHEET_DIMMED_LAYER + ABOVE; const TOAST = BOTTOM_SHEET_CONTAINER + ABOVE; +const SELECT_OPTION = ABOVE; export const ZINDEX = { bottomSheetDimmedLayer: BOTTOM_SHEET_DIMMED_LAYER, @@ -23,6 +24,7 @@ export const ZINDEX = { tabIndicator: TAB_INDICATOR, tabText: TAB_TEXT, toast: TOAST, + selectOption: SELECT_OPTION, } as const; type ZIndexKeys = @@ -34,6 +36,7 @@ type ZIndexKeys = | 'navBackgroundColor' | 'tabText' | 'tabIndicator' - | 'toast'; + | 'toast' + | 'selectOption'; export type ZIndexTokens = Record<ZIndexKeys, number>; diff --git a/client/src/constants/routerUrls.ts b/client/src/constants/routerUrls.ts index 802d509cd..535e9d825 100644 --- a/client/src/constants/routerUrls.ts +++ b/client/src/constants/routerUrls.ts @@ -8,4 +8,5 @@ export const ROUTER_URLS = { addBill: '/event/:eventId/add-bill', editBill: '/event/:eventId/edit-bill', eventEdit: 'event/:eventId/admin/edit', + send: 'event/:eventId/:memberId/send', }; diff --git a/client/src/constants/sessionStorageKeys.ts b/client/src/constants/sessionStorageKeys.ts new file mode 100644 index 000000000..da9097d5d --- /dev/null +++ b/client/src/constants/sessionStorageKeys.ts @@ -0,0 +1,5 @@ +const SESSION_STORAGE_KEYS = { + closeAccountBannerByEventToken: (eventToken: string) => `closeAccountBanner-${eventToken}`, +} as const; + +export default SESSION_STORAGE_KEYS; diff --git a/client/src/hooks/useAdminPage.ts b/client/src/hooks/useAdminPage.ts new file mode 100644 index 000000000..54794da08 --- /dev/null +++ b/client/src/hooks/useAdminPage.ts @@ -0,0 +1,55 @@ +import {useOutletContext} from 'react-router-dom'; +import {useEffect, useState} from 'react'; + +import {EventPageContextProps} from '@pages/EventPage/EventPageLayout'; + +import {useTotalExpenseAmountStore} from '@store/totalExpenseAmountStore'; + +import getEventIdByUrl from '@utils/getEventIdByUrl'; +import SessionStorage from '@utils/SessionStorage'; + +import SESSION_STORAGE_KEYS from '@constants/sessionStorageKeys'; + +import useRequestGetSteps from './queries/step/useRequestGetSteps'; +import useRequestPostAuthentication from './queries/auth/useRequestPostAuthentication'; + +const useAdminPage = () => { + const eventId = getEventIdByUrl(); + const {isAdmin, eventName, bankName, accountNumber} = useOutletContext<EventPageContextProps>(); + + const {totalExpenseAmount} = useTotalExpenseAmountStore(); + + const {steps} = useRequestGetSteps(); + const {postAuthenticate} = useRequestPostAuthentication(); + + useEffect(() => { + postAuthenticate(); + }, [postAuthenticate]); + + // session storage에 배너를 지웠는지 관리 + const storageValue = SessionStorage.get<boolean>(SESSION_STORAGE_KEYS.closeAccountBannerByEventToken(eventId)); + const isClosed = storageValue !== null && storageValue === true; + + const [isShowBanner, setIsShowBanner] = useState<boolean>((bankName === '' || accountNumber === '') && !isClosed); + + useEffect(() => { + setIsShowBanner((bankName === '' || accountNumber === '') && !isClosed); + }, [bankName, accountNumber, isShowBanner]); + + const onDelete = () => { + setIsShowBanner(false); + SessionStorage.set<boolean>(SESSION_STORAGE_KEYS.closeAccountBannerByEventToken(eventId), true); + }; + + return { + eventId, + isAdmin, + eventName, + totalExpenseAmount, + isShowBanner, + onDelete, + steps, + }; +}; + +export default useAdminPage; diff --git a/client/src/hooks/useReportsPage.ts b/client/src/hooks/useReportsPage.ts index 27083b297..ea7eea31a 100644 --- a/client/src/hooks/useReportsPage.ts +++ b/client/src/hooks/useReportsPage.ts @@ -1,41 +1,55 @@ import {useState} from 'react'; -import {useOutletContext} from 'react-router-dom'; +import {useLocation, useNavigate, useOutletContext} from 'react-router-dom'; import {EventPageContextProps} from '@pages/EventPage/EventPageLayout'; -import {ERROR_MESSAGE} from '@constants/errorMessage'; +import getDeletedLastPath from '@utils/getDeletedLastPath'; import {useSearchReports} from './useSearchReports'; import toast from './useToast/toast'; +export type SendInfo = { + bankName: string; + accountNumber: string; + amount: number; +}; + const useReportsPage = () => { const [memberName, setMemberName] = useState(''); const {bankName, accountNumber} = useOutletContext<EventPageContextProps>(); const {matchedReports, reports} = useSearchReports({memberName}); + const location = useLocation(); + const navigate = useNavigate(); + const changeName = ({target}: React.ChangeEvent<HTMLInputElement>) => { setMemberName(target.value); }; - const onBankButtonClick = (amount: number) => { - if (bankName.trim() === '' || accountNumber.trim() === '') { - toast.error(ERROR_MESSAGE.emptyBank, { - showingTime: 3000, - position: 'bottom', - }); - return; - } - - const url = `supertoss://send?amount=${amount}&bank=${bankName}&accountNo=${accountNumber}`; - window.location.href = url; + const onSendButtonClick = (memberId: number, amount: number) => { + const sendInfo: SendInfo = { + bankName, + accountNumber, + amount, + }; + + navigate(`${getDeletedLastPath(location.pathname)}/${memberId}/send`, {state: sendInfo}); }; - const expenseListProp = matchedReports.map(member => ({ - ...member, - onBankButtonClick, - })); + const onCopy = async (amount: number) => { + await window.navigator.clipboard.writeText(`${amount.toLocaleString('ko-kr')}원`); + toast.confirm('금액이 복사되었어요.'); + }; const isEmpty = reports.length <= 0; + const canSendBank = bankName !== '' && accountNumber !== ''; + + const expenseListProp = matchedReports.map(report => ({ + ...report, + canSendBank, + onCopy, + onSendButtonClick, + })); return { isEmpty, diff --git a/client/src/hooks/useSendPage.ts b/client/src/hooks/useSendPage.ts new file mode 100644 index 000000000..6b3cc9bf5 --- /dev/null +++ b/client/src/hooks/useSendPage.ts @@ -0,0 +1,100 @@ +import {useLocation} from 'react-router-dom'; +import {useEffect, useState} from 'react'; + +import {SendInfo} from './useReportsPage'; +import toast from './useToast/toast'; + +export type SendMethod = '복사하기' | '토스' | '카카오페이'; +export type OnSend = () => void | Promise<void>; + +const useSendPage = () => { + const [sendMethod, setSendMethod] = useState<SendMethod>('토스'); + const state = useLocation().state as SendInfo; + + const options: SendMethod[] = ['토스', '카카오페이', '복사하기']; + const defaultValue: SendMethod = '토스'; + + const onSelect = (option: SendMethod) => { + setSendMethod(option); + }; + + useEffect(() => { + if (!state) { + throw new Error('비정상적인 접근'); + } + }, [state]); + + const {bankName, accountNumber, amount} = state; + + const format = (accountNumber: string) => { + if (accountNumber.length > 9) { + return `${accountNumber.slice(0, 9)}...`; + } + return accountNumber; + }; + + const accountText = `${bankName} ${format(accountNumber)}으로`; + const amountText = `${amount.toLocaleString('ko-kr')}원을 송금할게요`; + + const copyText = `${bankName} ${accountNumber} ${amount}원`; + + const onCopy = async () => { + await window.navigator.clipboard.writeText(copyText); + toast.confirm('금액이 복사되었어요.'); + }; + + const onTossClick = () => { + const tossUrl = `supertoss://send?amount=${amount}&bank=${bankName}&accountNo=${accountNumber}`; + window.location.href = tossUrl; + }; + + const onKakaoPayClick = async () => { + await window.navigator.clipboard.writeText(copyText); + const kakaoPayUrl = 'kakaotalk://kakaopay/home'; + window.location.href = kakaoPayUrl; + }; + + const buttonText: Record<SendMethod, string> = { + 복사하기: '복사하기', + 토스: '송금하기', + 카카오페이: '송금하기', + }; + + const sendMethodIntroduceText: Record<SendMethod, string> = { + 복사하기: '복사하기 버튼을 누른 뒤 원하는 방법으로 직접 송금해 주세요', + 토스: '', + 카카오페이: '카카오페이 앱으로 이동한 뒤 송금 버튼을 눌러주세요', + }; + + const buttonOnClick: Record<SendMethod, OnSend> = { + 복사하기: onCopy, + 토스: onTossClick, + 카카오페이: onKakaoPayClick, + }; + + const topMessage = { + accountText, + amountText, + }; + + const selectProps = { + options, + defaultValue, + onSelect, + }; + + const selectResult = { + sendMethod, + buttonOnClick, + buttonText, + sendMethodIntroduceText, + }; + + return { + topMessage, + selectProps, + selectResult, + }; +}; + +export default useSendPage; diff --git a/client/src/hooks/useShareEvent.ts b/client/src/hooks/useShareEvent.ts index 782a3539f..7e2b6a39f 100644 --- a/client/src/hooks/useShareEvent.ts +++ b/client/src/hooks/useShareEvent.ts @@ -78,7 +78,7 @@ const useShareEvent = ({event, isMobile}: UserShareEventProps) => { return { shareText, - onShareButtonClick, + onShareButtonClick: kakaoShare, }; }; diff --git a/client/src/pages/ErrorPage/SendErrorPage/index.tsx b/client/src/pages/ErrorPage/SendErrorPage/index.tsx new file mode 100644 index 000000000..45ae4667c --- /dev/null +++ b/client/src/pages/ErrorPage/SendErrorPage/index.tsx @@ -0,0 +1,24 @@ +import {useNavigate} from 'react-router-dom'; + +import Top from '@components/Design/components/Top/Top'; + +import {Button, FunnelLayout, MainLayout} from '@HDesign/index'; + +const SendErrorPage = () => { + const navigate = useNavigate(); + + return ( + <MainLayout backgroundColor="white"> + <FunnelLayout> + <Top> + <Top.Line text="비정상적인 접근입니다." emphasize={['비정상적인 접근']} /> + </Top> + <Button size="large" color="primary" onClick={() => navigate(-1)}> + 뒤로가기 + </Button> + </FunnelLayout> + </MainLayout> + ); +}; + +export default SendErrorPage; diff --git a/client/src/pages/EventPage/AdminPage/AdminPage.tsx b/client/src/pages/EventPage/AdminPage/AdminPage.tsx index 668732ced..da4a11747 100644 --- a/client/src/pages/EventPage/AdminPage/AdminPage.tsx +++ b/client/src/pages/EventPage/AdminPage/AdminPage.tsx @@ -1,26 +1,18 @@ -import {useNavigate, useOutletContext} from 'react-router-dom'; +import {useNavigate} from 'react-router-dom'; import StepList from '@components/StepList/Steps'; -import useRequestGetSteps from '@hooks/queries/step/useRequestGetSteps'; +import {Banner} from '@components/Design/components/Banner'; -import {useTotalExpenseAmountStore} from '@store/totalExpenseAmountStore'; +import useAdminPage from '@hooks/useAdminPage'; import {Title, Button, Dropdown, DropdownButton} from '@HDesign/index'; -import getEventIdByUrl from '@utils/getEventIdByUrl'; - -import {EventPageContextProps} from '../EventPageLayout'; - import {receiptStyle} from './AdminPage.style'; const AdminPage = () => { const navigate = useNavigate(); - const eventId = getEventIdByUrl(); - const {isAdmin, eventName} = useOutletContext<EventPageContextProps>(); - - const {totalExpenseAmount} = useTotalExpenseAmountStore(); - const {steps} = useRequestGetSteps(); + const {eventId, isAdmin, eventName, totalExpenseAmount, isShowBanner, onDelete, steps} = useAdminPage(); const navigateAccountInputPage = () => { navigate(`/event/${eventId}/admin/edit`); @@ -30,6 +22,10 @@ const AdminPage = () => { navigate(`/event/${eventId}/admin/member`); }; + const navigateAddBill = () => { + navigate(`/event/${eventId}/add-bill`); + }; + return ( <section css={receiptStyle}> <Title @@ -42,8 +38,17 @@ const AdminPage = () => { </Dropdown> } /> - <StepList data={steps ?? []} isAdmin={isAdmin} /> - <Button size="medium" onClick={() => navigate(`/event/${eventId}/add-bill`)} style={{width: '100%'}}> + {isShowBanner && ( + <Banner + onClick={navigateAccountInputPage} + onDelete={onDelete} + title="계좌번호가 등록되지 않았어요" + description="계좌번호를 입력해야 참여자가 편하게 송금할 수 있어요" + buttonText="등록하기" + /> + )} + {steps.length > 0 && <StepList data={steps ?? []} isAdmin={isAdmin} />} + <Button size="medium" onClick={navigateAddBill} style={{width: '100%'}}> 지출내역 추가하기 </Button> </section> diff --git a/client/src/pages/SendPage/index.tsx b/client/src/pages/SendPage/index.tsx new file mode 100644 index 000000000..88432173e --- /dev/null +++ b/client/src/pages/SendPage/index.tsx @@ -0,0 +1,36 @@ +import {useNavigate} from 'react-router-dom'; + +import useSendPage from '@hooks/useSendPage'; + +import {FixedButton, FunnelLayout, MainLayout, Select, Text, Top, TopNav} from '@components/Design'; + +const SendPage = () => { + const {topMessage, selectProps, selectResult} = useSendPage(); + const {accountText, amountText} = topMessage; + const {sendMethod, sendMethodIntroduceText, buttonOnClick, buttonText} = selectResult; + + const navigate = useNavigate(); + + return ( + <MainLayout backgroundColor="white"> + <TopNav> + <TopNav.Item displayName="뒤로가기" noEmphasis routePath="-1" /> + </TopNav> + <FunnelLayout> + <Top> + <Top.Line text={accountText} /> + <Top.Line text={amountText} emphasize={[amountText]} /> + </Top> + <Select labelText="송금 방법 선택" placeholder="송금 방법 선택" {...selectProps} /> + <Text size="body" textColor="gray"> + {sendMethodIntroduceText[sendMethod]} + </Text> + <FixedButton onBackClick={() => navigate(-1)} onClick={buttonOnClick[sendMethod]}> + {buttonText[sendMethod]} + </FixedButton> + </FunnelLayout> + </MainLayout> + ); +}; + +export default SendPage; diff --git a/client/src/router.tsx b/client/src/router.tsx index d0e376eb0..a5d444897 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -4,9 +4,11 @@ import {lazy, Suspense} from 'react'; import ErrorPage from '@pages/ErrorPage/ErrorPage'; import EventLoginPage from '@pages/EventPage/AdminPage/EventLoginPage'; import EventLoader from '@components/Loader/EventLoader'; +import SendErrorPage from '@pages/ErrorPage/SendErrorPage'; import AuthGate from '@pages/EventPage/AuthGate'; import {EventPage} from '@pages/EventPage'; +import SendPage from '@pages/SendPage'; import {ROUTER_URLS} from '@constants/routerUrls'; @@ -75,6 +77,11 @@ const router = createBrowserRouter([ path: ROUTER_URLS.eventEdit, element: <Account />, }, + { + path: ROUTER_URLS.send, + element: <SendPage />, + errorElement: <SendErrorPage />, + }, { path: '*', element: <ErrorPage />, diff --git a/client/src/utils/SessionStorage.ts b/client/src/utils/SessionStorage.ts new file mode 100644 index 000000000..42527d8c7 --- /dev/null +++ b/client/src/utils/SessionStorage.ts @@ -0,0 +1,19 @@ +const SessionStorage = { + get: <T>(key: string): T | null => { + const savedElement = sessionStorage.getItem(key); + + if (savedElement === null) { + return null; + } + + const element = JSON.parse(savedElement) as T; + return element; + }, + + set: <T>(key: string, data: T) => { + const element = JSON.stringify(data); + sessionStorage.setItem(key, element); + }, +}; + +export default SessionStorage; From fef55c12f3fe1791b222807254983c77e8105195 Mon Sep 17 00:00:00 2001 From: Soyeon Choe <77609591+soi-ha@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:04:00 +0900 Subject: [PATCH 09/20] =?UTF-8?q?feat:=20Footer=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20(#709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/components/Footer/Footer.style.ts | 26 +++++++++++++++++ client/src/components/Footer/Footer.tsx | 29 +++++++++++++++++++ client/src/components/Footer/Footer.type.ts | 7 +++++ client/src/components/Footer/index.ts | 1 + .../src/pages/EventPage/EventPageLayout.tsx | 2 ++ 5 files changed, 65 insertions(+) create mode 100644 client/src/components/Footer/Footer.style.ts create mode 100644 client/src/components/Footer/Footer.tsx create mode 100644 client/src/components/Footer/Footer.type.ts create mode 100644 client/src/components/Footer/index.ts diff --git a/client/src/components/Footer/Footer.style.ts b/client/src/components/Footer/Footer.style.ts new file mode 100644 index 000000000..fa67ded51 --- /dev/null +++ b/client/src/components/Footer/Footer.style.ts @@ -0,0 +1,26 @@ +import {css} from '@emotion/react'; + +import {Theme} from '@components/Design/theme/theme.type'; +import TYPOGRAPHY from '@components/Design/token/typography'; + +export const footerStyle = (theme: Theme) => + css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '0.625rem', + marginTop: 'auto', + marginBottom: '1.25rem', + color: theme.colors.gray, + + '.footer-link-bundle': { + display: 'flex', + flexDirection: 'row', + gap: '0.625rem', + }, + + a: { + borderBottom: `1px solid ${theme.colors.gray}`, + ...TYPOGRAPHY.tiny, + }, + }); diff --git a/client/src/components/Footer/Footer.tsx b/client/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..b75be7ed9 --- /dev/null +++ b/client/src/components/Footer/Footer.tsx @@ -0,0 +1,29 @@ +import {Link} from 'react-router-dom'; + +import {useTheme, Text} from '@components/Design'; + +import {footerStyle} from './Footer.style'; +import {FooterProps} from './Footer.type'; + +const Footer: React.FC<FooterProps> = () => { + const {theme} = useTheme(); + const year = new Date().getFullYear(); + + return ( + <footer css={footerStyle(theme)}> + <div className="footer-link-bundle"> + <a href="https://github.com/woowacourse-teams/2024-haeng-dong/wiki" target="_blank"> + 행동대장 소개 + </a> + {/* TODO: (@soha) 문의 페이지 링크로 꼭!! 추후 수정 */} + <Link to={'/'}>문의하기</Link> + <a href="mailto:haengdongdj@gmail.com">이메일</a> + </div> + <Text size="tiny" textColor="gray"> + © {year} Copyright 행동대장 + </Text> + </footer> + ); +}; + +export default Footer; diff --git a/client/src/components/Footer/Footer.type.ts b/client/src/components/Footer/Footer.type.ts new file mode 100644 index 000000000..91f32e7f3 --- /dev/null +++ b/client/src/components/Footer/Footer.type.ts @@ -0,0 +1,7 @@ +export interface FooterStyleProps {} + +export interface FooterCustomProps {} + +export type FooterOptionProps = FooterStyleProps & FooterCustomProps; + +export type FooterProps = React.ComponentProps<'footer'> & FooterOptionProps; diff --git a/client/src/components/Footer/index.ts b/client/src/components/Footer/index.ts new file mode 100644 index 000000000..6a5d76c69 --- /dev/null +++ b/client/src/components/Footer/index.ts @@ -0,0 +1 @@ +export {default as Footer} from './Footer'; diff --git a/client/src/pages/EventPage/EventPageLayout.tsx b/client/src/pages/EventPage/EventPageLayout.tsx index dd28bc300..dfc7e42ec 100644 --- a/client/src/pages/EventPage/EventPageLayout.tsx +++ b/client/src/pages/EventPage/EventPageLayout.tsx @@ -5,6 +5,7 @@ import {Outlet} from 'react-router-dom'; import useEventPageLayout from '@hooks/useEventPageLayout'; import useShareEvent from '@hooks/useShareEvent'; +import {Footer} from '@components/Footer'; import {DesktopShareEventButton, MobileShareEventButton} from '@components/ShareEventButton'; import {Flex, Icon, IconButton, MainLayout, TopNav} from '@HDesign/index'; @@ -45,6 +46,7 @@ const EventPageLayout = () => { )} </Flex> <Outlet context={outletContext} /> + <Footer /> </MainLayout> ); }; From 902b89a6d1f399707ac2cd46d8bf729ce1157849 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:04:22 +0900 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20=ED=86=A0=EC=8A=A4=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=EC=97=90=20=EB=A7=9E=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EC=9D=80=ED=96=89=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95=20(#7?= =?UTF-8?q?13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/BankSelect/BankSelect.tsx | 4 +- client/src/constants/bank.ts | 47 ++++++++++--------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/client/src/components/Design/components/BankSelect/BankSelect.tsx b/client/src/components/Design/components/BankSelect/BankSelect.tsx index 5fdc6f2df..94b08b54c 100644 --- a/client/src/components/Design/components/BankSelect/BankSelect.tsx +++ b/client/src/components/Design/components/BankSelect/BankSelect.tsx @@ -13,12 +13,12 @@ type BankSelectProps = { const BankSelect = ({onSelect}: BankSelectProps) => { return ( <div css={bankSelectStyle}> - {BANKS.map(({name, iconPosition}) => ( + {BANKS.map(({name, displayName, iconPosition}) => ( <button onClick={() => onSelect(name)} key={name}> <Flex flexDirection="column" alignItems="center" gap="0.5rem" width="100%"> <div css={iconStyle(iconPosition)} /> <Text size="body" textColor="black" style={{textAlign: 'center'}}> - {name} + {displayName} </Text> </Flex> </button> diff --git a/client/src/constants/bank.ts b/client/src/constants/bank.ts index b730adb44..1024a1249 100644 --- a/client/src/constants/bank.ts +++ b/client/src/constants/bank.ts @@ -1,32 +1,33 @@ type Bank = { name: string; + displayName: string; iconPosition: string; }; const BANKS: Bank[] = [ - {name: '우리은행', iconPosition: '-10px -10px'}, - {name: '제일은행', iconPosition: '-110px -10px'}, - {name: '신한은행', iconPosition: '-10px -110px'}, - {name: 'KB국민은행', iconPosition: '-110px -110px'}, - {name: '하나은행', iconPosition: '-210px -10px'}, - {name: '시티은행', iconPosition: '-210px -110px'}, - {name: 'IM뱅크', iconPosition: '-10px -210px'}, - {name: '부산은행', iconPosition: '-110px -210px'}, - {name: '경남은행', iconPosition: '-210px -210px'}, - {name: '광주은행', iconPosition: '-310px -10px'}, - {name: '전북은행', iconPosition: '-310px -110px'}, - {name: '제주은행', iconPosition: '-310px -210px'}, - {name: '기업은행', iconPosition: '-10px -310px'}, - {name: '산업은행', iconPosition: '-110px -310px'}, - {name: '수협은행', iconPosition: '-210px -310px'}, - {name: '농협은행', iconPosition: '-310px -310px'}, - {name: '새마을금고', iconPosition: '-410px -10px'}, - {name: '우체국은행', iconPosition: '-410px -110px'}, - {name: '신협은행', iconPosition: '-410px -210px'}, - {name: 'SBI저축', iconPosition: '-410px -310px'}, - {name: '카카오뱅크', iconPosition: '-10px -410px'}, - {name: '토스뱅크', iconPosition: '-110px -410px'}, - {name: '케이뱅크', iconPosition: '-210px -410px'}, + {name: '우리은행', displayName: '우리은행', iconPosition: '-10px -10px'}, + {name: 'SC제일은행', displayName: '제일은행', iconPosition: '-110px -10px'}, + {name: '신한은행', displayName: '신한은행', iconPosition: '-10px -110px'}, + {name: 'KB국민은행', displayName: 'KB국민은행', iconPosition: '-110px -110px'}, + {name: '하나은행', displayName: '하나은행', iconPosition: '-210px -10px'}, + {name: '씨티은행', displayName: '씨티은행', iconPosition: '-210px -110px'}, + {name: 'IM뱅크', displayName: 'IM뱅크', iconPosition: '-10px -210px'}, + {name: '부산은행', displayName: '부산은행', iconPosition: '-110px -210px'}, + {name: '경남은행', displayName: '경남은행', iconPosition: '-210px -210px'}, + {name: '광주은행', displayName: '광주은행', iconPosition: '-310px -10px'}, + {name: '전북은행', displayName: '전북은행', iconPosition: '-310px -110px'}, + {name: '제주은행', displayName: '제주은행', iconPosition: '-310px -210px'}, + {name: 'IBK기업은행', displayName: '기업은행', iconPosition: '-10px -310px'}, + {name: 'KDB산업은행', displayName: '산업은행', iconPosition: '-110px -310px'}, + {name: '수협은행', displayName: '수협은행', iconPosition: '-210px -310px'}, + {name: 'NH농협', displayName: '농협은행', iconPosition: '-310px -310px'}, + {name: '새마을금고', displayName: '새마을금고', iconPosition: '-410px -10px'}, + {name: '우체국은행', displayName: '우체국은행', iconPosition: '-410px -110px'}, + {name: '신협은행', displayName: '신협은행', iconPosition: '-410px -210px'}, + {name: 'SBI저축', displayName: 'SBI저축', iconPosition: '-410px -310px'}, + {name: '카카오뱅크', displayName: '카카오뱅크', iconPosition: '-10px -410px'}, + {name: '토스뱅크', displayName: '토스뱅크', iconPosition: '-110px -410px'}, + {name: '케이뱅크', displayName: '케이뱅크', iconPosition: '-210px -410px'}, ]; export default BANKS; From 531442c7cb1a496f9e99d1b38f3b4aeba7d03365 Mon Sep 17 00:00:00 2001 From: Pakxe <64801796+pakxe@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:10:28 +0900 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=A0=20=EB=95=8C=20=EB=B2=84=ED=8A=BC=EC=9D=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=95=A8=20(#715)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * design: Button 컴포넌트에 Input의 height와 똑같은 크기의 semiLarge 사이즈 추가 * refactor: Flex컴포넌트의 스타일 추가 프롭을 otherStyle -> cssProp으로 이름 수정 * feat: emotion 에서 사용하는 css타입으로 Flex 컴포넌트의 스타일을 주입할 수 있도록 수정 * chore: Flex 컴포넌트의 프롭명이 달라짐에 따른 수정 * design: Input 컴포넌트가 width100%를 차지하도록 함 * feat: 멤버 추가 버튼을 클릭했을 때 멤버를 추가하도록 함수 구현 * feat: 멤버 스텝에 멤버 '추가'버튼을 추가하고 클릭되었을 때 실행할 로직 연결 * chore: props 이름 변경으로 인한 수정 --- .../Design/components/Banner/Banner.tsx | 2 +- .../Design/components/Button/Button.style.ts | 9 +++++ .../Design/components/Button/Button.type.ts | 2 +- .../components/Dropdown/Dropdown.style.ts | 2 +- .../components/ExpenseList/ExpenseList.tsx | 2 +- .../Design/components/Flex/Flex.tsx | 8 ++--- .../Design/components/Flex/Flex.type.ts | 7 ++-- .../Design/components/Input/Input.tsx | 2 +- client/src/hooks/useMembersStep.ts | 7 ++++ .../pages/AddBillFunnel/steps/MembersStep.tsx | 33 +++++++++++-------- 10 files changed, 49 insertions(+), 25 deletions(-) diff --git a/client/src/components/Design/components/Banner/Banner.tsx b/client/src/components/Design/components/Banner/Banner.tsx index 9cb2ddca2..a7ac43c59 100644 --- a/client/src/components/Design/components/Banner/Banner.tsx +++ b/client/src/components/Design/components/Banner/Banner.tsx @@ -16,7 +16,7 @@ const Banner = ({title, description, buttonText, onDelete, ...buttonProps}: Bann padding="0.5rem" paddingInline="0.5rem" backgroundColor="white" - otherStyle={{borderRadius: '0.75rem'}} + cssProp={{borderRadius: '0.75rem'}} > <Flex gap="0.5rem"> <IconButton variants="none" onClick={onDelete} style={{display: 'flex', alignItems: 'flex-start'}}> diff --git a/client/src/components/Design/components/Button/Button.style.ts b/client/src/components/Design/components/Button/Button.style.ts index 0d274f116..e17c9db92 100644 --- a/client/src/components/Design/components/Button/Button.style.ts +++ b/client/src/components/Design/components/Button/Button.style.ts @@ -17,6 +17,7 @@ const getButtonDefaultStyle = (theme: Theme) => css({ display: 'flex', justifyContent: 'center', + alignItems: 'center', lineHeight: '1', transition: '0.2s', transitionTimingFunction: 'cubic-bezier(0.7, 0.62, 0.62, 1.16)', @@ -57,6 +58,14 @@ const getButtonSizeStyle = (size: ButtonSize) => { fontSize: '1rem', fontWeight: '700', }), + semiLarge: css({ + padding: '0.75rem 1rem', + borderRadius: '1rem', + fontFamily: 'Pretendard', + fontSize: '1rem', + fontWeight: '700', + height: '3rem', + }), large: css({ padding: '1rem 1.5rem', borderRadius: '1rem', diff --git a/client/src/components/Design/components/Button/Button.type.ts b/client/src/components/Design/components/Button/Button.type.ts index a0402b1dc..512e81105 100644 --- a/client/src/components/Design/components/Button/Button.type.ts +++ b/client/src/components/Design/components/Button/Button.type.ts @@ -1,6 +1,6 @@ import {Theme} from '@theme/theme.type'; -export type ButtonSize = 'small' | 'medium' | 'large'; +export type ButtonSize = 'small' | 'medium' | 'semiLarge' | 'large'; export type ButtonVariants = 'primary' | 'secondary' | 'tertiary' | 'destructive' | 'loading'; export interface ButtonStyleProps { diff --git a/client/src/components/Design/components/Dropdown/Dropdown.style.ts b/client/src/components/Design/components/Dropdown/Dropdown.style.ts index 8b4fc91df..0074a5be6 100644 --- a/client/src/components/Design/components/Dropdown/Dropdown.style.ts +++ b/client/src/components/Design/components/Dropdown/Dropdown.style.ts @@ -12,7 +12,7 @@ export const dropdownStyle: FlexProps = { gap: '0.25rem', backgroundColor: 'white', - otherStyle: { + cssProp: { position: 'absolute', top: '2rem', right: '-1rem', diff --git a/client/src/components/Design/components/ExpenseList/ExpenseList.tsx b/client/src/components/Design/components/ExpenseList/ExpenseList.tsx index 8ccaa7ef0..4e220d6d0 100644 --- a/client/src/components/Design/components/ExpenseList/ExpenseList.tsx +++ b/client/src/components/Design/components/ExpenseList/ExpenseList.tsx @@ -61,7 +61,7 @@ function ExpenseList({memberName, onSearch, placeholder, expenseList = []}: Expe paddingInline="0.5rem" gap="0.5rem" height="100%" - otherStyle={{borderRadius: '1rem'}} + cssProp={{borderRadius: '1rem'}} > <Input inputType="search" value={memberName} onChange={onSearch} placeholder={placeholder} /> {expenseList.length !== 0 && expenseList.map(expense => <ExpenseItem key={expense.memberId} {...expense} />)} diff --git a/client/src/components/Design/components/Flex/Flex.tsx b/client/src/components/Design/components/Flex/Flex.tsx index 51341dccf..87cddbb01 100644 --- a/client/src/components/Design/components/Flex/Flex.tsx +++ b/client/src/components/Design/components/Flex/Flex.tsx @@ -8,8 +8,7 @@ import {useTheme} from '../../index'; import {FlexProps} from './Flex.type'; import {flexStyle} from './Flex.style'; -// TODO: (@weadie) 지정된 프롭 말고 다른 프롭도 가져올 수 있게 하자. -const Flex = forwardRef<HTMLDivElement, StrictPropsWithChildren<FlexProps>>(({children, otherStyle, ...props}, ref) => { +const Flex = forwardRef<HTMLDivElement, StrictPropsWithChildren<FlexProps>>(({children, cssProp, ...props}, ref) => { const {theme} = useTheme(); const { @@ -30,7 +29,7 @@ const Flex = forwardRef<HTMLDivElement, StrictPropsWithChildren<FlexProps>>(({ch return ( <div ref={ref} - css={flexStyle({ + css={[flexStyle({ theme, justifyContent, alignItems, @@ -43,8 +42,7 @@ const Flex = forwardRef<HTMLDivElement, StrictPropsWithChildren<FlexProps>>(({ch height, backgroundColor, minHeight, - })} - style={{...otherStyle}} + }), cssProp]} {...htmlProps} > {children} diff --git a/client/src/components/Design/components/Flex/Flex.type.ts b/client/src/components/Design/components/Flex/Flex.type.ts index e251db35c..ec393e292 100644 --- a/client/src/components/Design/components/Flex/Flex.type.ts +++ b/client/src/components/Design/components/Flex/Flex.type.ts @@ -1,3 +1,5 @@ +import {CSSObject} from '@emotion/react'; + import {Theme} from '../../theme/theme.type'; export type FlexDirectionType = 'row' | 'column' | 'rowReverse' | 'columnReverse'; @@ -18,5 +20,6 @@ export type FlexProps = React.HTMLAttributes<HTMLDivElement> & { theme?: Theme; minHeight?: string; - otherStyle?: React.CSSProperties; -}; + cssProp?: CSSObject; +} + diff --git a/client/src/components/Design/components/Input/Input.tsx b/client/src/components/Design/components/Input/Input.tsx index f9cbe0858..81f0a025b 100644 --- a/client/src/components/Design/components/Input/Input.tsx +++ b/client/src/components/Design/components/Input/Input.tsx @@ -43,7 +43,7 @@ export const Input: React.FC<InputProps> = forwardRef<HTMLInputElement, InputPro }, []); return ( - <Flex flexDirection="column" gap="0.375rem"> + <Flex flexDirection="column" gap="0.375rem" cssProp={{width: '100%'}}> {(labelText || errorText) && ( <Flex justifyContent="spaceBetween" paddingInline="0.5rem" margin="0 0 0.375rem 0"> {labelText && ( diff --git a/client/src/hooks/useMembersStep.ts b/client/src/hooks/useMembersStep.ts index dba0aeacd..e494884b5 100644 --- a/client/src/hooks/useMembersStep.ts +++ b/client/src/hooks/useMembersStep.ts @@ -82,6 +82,12 @@ const useMembersStep = ({billInfo, setBillInfo, currentMembers, setStep}: Props) } }; + const handleNameInputComplete = (event: React.MouseEvent<HTMLButtonElement>) => { + if (!canAddMembers || !inputRef.current) return; + event.preventDefault(); + addMembersFromInput(); + }; + const handlePostBill = async () => { if (billInfo.members.map(({id}) => id).includes(-1)) { const newMembers = await postMembersAsync({ @@ -124,6 +130,7 @@ const useMembersStep = ({billInfo, setBillInfo, currentMembers, setStep}: Props) hiddenRef, handleNameInputChange, handleNameInputEnter, + handleNameInputComplete, isPendingPostBill, isPendingPostMembers, canSubmitMembers, diff --git a/client/src/pages/AddBillFunnel/steps/MembersStep.tsx b/client/src/pages/AddBillFunnel/steps/MembersStep.tsx index 5baf92ecf..32399bd59 100644 --- a/client/src/pages/AddBillFunnel/steps/MembersStep.tsx +++ b/client/src/pages/AddBillFunnel/steps/MembersStep.tsx @@ -8,7 +8,7 @@ import {Member} from 'types/serviceType'; import useMembersStep from '@hooks/useMembersStep'; import {BillStep} from '@hooks/useAddBillFunnel'; -import {FixedButton, Flex, Input, Text} from '@components/Design'; +import {Button, FixedButton, Flex, Input, Text} from '@components/Design'; import {isIOS} from '@utils/detectDevice'; @@ -29,6 +29,7 @@ const MembersStep = ({billInfo, setBillInfo, currentMembers, setStep}: Props) => hiddenRef, handleNameInputChange, handleNameInputEnter, + handleNameInputComplete, isPendingPostBill, isPendingPostMembers, canSubmitMembers, @@ -51,18 +52,24 @@ const MembersStep = ({billInfo, setBillInfo, currentMembers, setStep}: Props) => <Top.Line text="참여한 사람은 누구인가요?" emphasize={['참여한 사람']} /> </Top> - <Input - ref={inputRef} - labelText="이름" - errorText={errorMessage ?? ''} - value={nameInput} - type="text" - placeholder="ex) 박행댕" - onChange={handleNameInputChange} - isError={!!errorMessage} - autoFocus - onKeyDown={handleNameInputEnter} - /> + <Flex flexDirection="row" alignItems="flexEnd" gap="0.5rem"> + <Input + ref={inputRef} + labelText="이름" + errorText={errorMessage ?? ''} + value={nameInput} + type="text" + placeholder="ex) 박행댕" + onChange={handleNameInputChange} + isError={!!errorMessage} + autoFocus + onKeyDown={handleNameInputEnter} + /> + <Button size="semiLarge" onClick={handleNameInputComplete}> + 추가 + </Button> + </Flex> + <div css={css` display: flex; From 0f22c768663ef467fcf9f1fbaed27482bf260bb1 Mon Sep 17 00:00:00 2001 From: Soyeon Choe <77609591+soi-ha@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:11:07 +0900 Subject: [PATCH 12/20] =?UTF-8?q?=08fix:=20react-query=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=B4=20=EB=B0=9C=EC=83=9D=ED=95=9C=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: onSuccess에 currentMembers queryKey refetching 추가하기 * fix: 행사를 생성할 때 마다, queryKey event refetching 추가하기 * fix: 새로운 행사 생성시 기존(이전 행사)의 query 모두 제거 --- client/src/hooks/queries/bill/useRequestDeleteBill.ts | 1 + client/src/hooks/queries/bill/useRequestPostBill.ts | 1 + client/src/hooks/queries/bill/useRequestPutBill.ts | 1 + client/src/hooks/queries/bill/useRequestPutBillDetails.ts | 1 + client/src/hooks/queries/event/useRequestPostEvent.ts | 6 +++++- client/src/hooks/queries/member/useRequestDeleteMember.ts | 1 + client/src/hooks/queries/member/useRequestPostMembers.ts | 1 + client/src/hooks/queries/member/useRequestPutMembers.ts | 1 + 8 files changed, 12 insertions(+), 1 deletion(-) diff --git a/client/src/hooks/queries/bill/useRequestDeleteBill.ts b/client/src/hooks/queries/bill/useRequestDeleteBill.ts index 68083d52b..57e41c1db 100644 --- a/client/src/hooks/queries/bill/useRequestDeleteBill.ts +++ b/client/src/hooks/queries/bill/useRequestDeleteBill.ts @@ -17,6 +17,7 @@ const useRequestDeleteBill = () => { onSuccess: () => { queryClient.invalidateQueries({queryKey: [QUERY_KEYS.steps]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.reports]}); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.currentMembers]}); }, }); diff --git a/client/src/hooks/queries/bill/useRequestPostBill.ts b/client/src/hooks/queries/bill/useRequestPostBill.ts index a9c7bce62..a5caa3d63 100644 --- a/client/src/hooks/queries/bill/useRequestPostBill.ts +++ b/client/src/hooks/queries/bill/useRequestPostBill.ts @@ -15,6 +15,7 @@ const useRequestPostBill = () => { onSuccess: () => { queryClient.invalidateQueries({queryKey: [QUERY_KEYS.steps]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.reports]}); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.currentMembers]}); }, }); diff --git a/client/src/hooks/queries/bill/useRequestPutBill.ts b/client/src/hooks/queries/bill/useRequestPutBill.ts index 88a47f6bf..23f810ef6 100644 --- a/client/src/hooks/queries/bill/useRequestPutBill.ts +++ b/client/src/hooks/queries/bill/useRequestPutBill.ts @@ -17,6 +17,7 @@ const useRequestPutBill = () => { onSuccess: () => { queryClient.invalidateQueries({queryKey: [QUERY_KEYS.steps]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.reports]}); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.currentMembers]}); }, }); diff --git a/client/src/hooks/queries/bill/useRequestPutBillDetails.ts b/client/src/hooks/queries/bill/useRequestPutBillDetails.ts index 9e9bb3e0f..ae2025bcd 100644 --- a/client/src/hooks/queries/bill/useRequestPutBillDetails.ts +++ b/client/src/hooks/queries/bill/useRequestPutBillDetails.ts @@ -18,6 +18,7 @@ const useRequestPutBillDetails = ({billId}: WithBillId) => { onSuccess: () => { queryClient.invalidateQueries({queryKey: [QUERY_KEYS.steps]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.reports]}); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.currentMembers]}); queryClient.removeQueries({queryKey: [QUERY_KEYS.billDetails, billId]}); }, // onMutate: async (newMembers: MemberReportInAction[]) => { diff --git a/client/src/hooks/queries/event/useRequestPostEvent.ts b/client/src/hooks/queries/event/useRequestPostEvent.ts index d59f6b913..0f30b48e1 100644 --- a/client/src/hooks/queries/event/useRequestPostEvent.ts +++ b/client/src/hooks/queries/event/useRequestPostEvent.ts @@ -1,10 +1,14 @@ -import {useMutation} from '@tanstack/react-query'; +import {useMutation, useQueryClient} from '@tanstack/react-query'; import {RequestPostEvent, requestPostEvent} from '@apis/request/event'; const useRequestPostEvent = () => { + const queryClient = useQueryClient(); const {mutate, mutateAsync, ...rest} = useMutation({ mutationFn: ({eventName, password}: RequestPostEvent) => requestPostEvent({eventName, password}), + onSuccess: () => { + queryClient.removeQueries(); + }, }); // 실행 순서를 await으로 보장하기 위해 mutateAsync 사용 diff --git a/client/src/hooks/queries/member/useRequestDeleteMember.ts b/client/src/hooks/queries/member/useRequestDeleteMember.ts index 770218e6b..2003f8650 100644 --- a/client/src/hooks/queries/member/useRequestDeleteMember.ts +++ b/client/src/hooks/queries/member/useRequestDeleteMember.ts @@ -15,6 +15,7 @@ const useRequestDeleteMember = () => { onSuccess: () => { queryClient.invalidateQueries({queryKey: [QUERY_KEYS.steps]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.allMembers]}); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.currentMembers]}); queryClient.removeQueries({queryKey: [QUERY_KEYS.billDetails]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.reports]}); }, diff --git a/client/src/hooks/queries/member/useRequestPostMembers.ts b/client/src/hooks/queries/member/useRequestPostMembers.ts index bddb9a322..124620e0b 100644 --- a/client/src/hooks/queries/member/useRequestPostMembers.ts +++ b/client/src/hooks/queries/member/useRequestPostMembers.ts @@ -16,6 +16,7 @@ const useRequestPostMembers = () => { queryClient.invalidateQueries({queryKey: [QUERY_KEYS.allMembers]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.steps]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.reports]}); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.currentMembers]}); return responseData; }, }); diff --git a/client/src/hooks/queries/member/useRequestPutMembers.ts b/client/src/hooks/queries/member/useRequestPutMembers.ts index 69df92c4c..9b71b389b 100644 --- a/client/src/hooks/queries/member/useRequestPutMembers.ts +++ b/client/src/hooks/queries/member/useRequestPutMembers.ts @@ -15,6 +15,7 @@ const useRequestPutMembers = () => { onSuccess: () => { queryClient.invalidateQueries({queryKey: [QUERY_KEYS.steps]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.allMembers]}); + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.currentMembers]}); queryClient.removeQueries({queryKey: [QUERY_KEYS.billDetails]}); queryClient.invalidateQueries({queryKey: [QUERY_KEYS.reports]}); }, From 835b8d3367326d9707a97f14fb75d00ce3001825 Mon Sep 17 00:00:00 2001 From: Pakxe <64801796+pakxe@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:11:58 +0900 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20toast.error,=20toast.none=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EB=8F=84=20=EB=AA=A8=EB=91=90=20toast.confirm?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=B6=9C=EB=A0=A5=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20(#720)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 사용하지 않고있는 변수 제거 * fix: 토스트 타입에 따라 다른 아이콘이 뜨도록 타입을 제대로 명시해서 넘겨줌 --- client/src/hooks/useToast/toast.ts | 13 +++++++------ client/src/hooks/useToast/useToast.tsx | 5 ----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/client/src/hooks/useToast/toast.ts b/client/src/hooks/useToast/toast.ts index 362227a62..71f3e69ba 100644 --- a/client/src/hooks/useToast/toast.ts +++ b/client/src/hooks/useToast/toast.ts @@ -1,19 +1,20 @@ import {ToastMessage, ToastOptions} from 'types/toastType'; +import {ToastType} from '@components/Toast/Toast.type'; import toastEventManager from './toastEventManager'; import {TOAST_EVENT} from './toastEventManager.type'; -const showToast = (message: ToastMessage, options: ToastOptions) => { - return toastEventManager.trigger(TOAST_EVENT.show, message, options); +const showToast = (message: ToastMessage, options: ToastOptions, type?: ToastType) => { + return toastEventManager.trigger(TOAST_EVENT.show, message, {...options, type}); }; // toast('안녕') 처럼도 사용할 수 있도록 const toast = (message: ToastMessage, options: ToastOptions = {}) => { - return showToast(message, options); + return showToast(message, options, 'none'); }; -toast.error = (message: ToastMessage, options: ToastOptions = {}) => showToast(message, options); -toast.confirm = (message: ToastMessage, options: ToastOptions = {}) => showToast(message, options); -toast.none = (message: ToastMessage, options: ToastOptions = {}) => showToast(message, options); +toast.error = (message: ToastMessage, options: ToastOptions = {}) => showToast(message, options, 'error'); +toast.confirm = (message: ToastMessage, options: ToastOptions = {}) => showToast(message, options, 'confirm'); +toast.none = (message: ToastMessage, options: ToastOptions = {}) => showToast(message, options, 'none'); export default toast; diff --git a/client/src/hooks/useToast/useToast.tsx b/client/src/hooks/useToast/useToast.tsx index e47d99c50..c9deb2f97 100644 --- a/client/src/hooks/useToast/useToast.tsx +++ b/client/src/hooks/useToast/useToast.tsx @@ -8,11 +8,6 @@ import {TOAST_EVENT} from './toastEventManager.type'; const DEFAULT_TIME = 3000; -type Toast = { - message: ToastMessage; - options: ToastOptions; -}; - export const useToast = () => { const [currentToast, setCurrentToast] = useState<ToastArgs | null>(null); From 81fa1cf43c9a1a38846be1284b9e53335d3d2974 Mon Sep 17 00:00:00 2001 From: TaehunLee <85233397+Todari@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:23:20 +0900 Subject: [PATCH 14/20] =?UTF-8?q?feat:=20=EC=82=AC=EC=A7=84=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20(#723)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 기존에 잘못된 url 주소 변경 * feat: carousel 구현 * fix: api 요청 방법 변경 * fix: image post api 수정 * feat: image 불러오기 위한 기능 구현 * fix: 스크롤바가 보이지 않도록 변경 * design: Carousel 디자인 변경 * fix: 사진 추가 버튼 커스텀 * feat: images 불러오는 api 추가 * feat: Carousel component 구현 완료 * feat: 사진 추가 페이지 구현 * feat: 요청으로 불러오는 Images 타입 선언 * feat: 홈에서 이미지를 확인할 수 있는 기능 추가 * fix: 행사 생성 시 다른 query들 초기화하도록 변경 * fix: style이 제대로 적용되지 않던 문제 해결 * style: lint 적용 * fix: mock url 변경 * feat: imageDelete api 추가 * fix: service type 수정 * refactor: AddImagesPage 구조 변경 및 delete api 추가 * fix: 불필요한 z-index 제거₩ * fix: merge 충돌 해결 --- client/src/GlobalStyle.ts | 6 ++ client/src/apis/baseUrl.ts | 1 + client/src/apis/request/images.ts | 51 ++++++++++ client/src/assets/image/photoButton.svg | 4 + .../components/Carousel/Carousel.stories.tsx | 27 ++++++ .../components/Carousel/Carousel.style.ts | 94 ++++++++++++++++++ .../Design/components/Carousel/Carousel.tsx | 36 +++++++ .../components/Carousel/Carousel.type.ts | 4 + .../Carousel/CarouselDeleteButton.tsx | 17 ++++ .../components/Carousel/CarouselIndicator.tsx | 22 +++++ .../components/Carousel/useCarousel.tsx | 48 ++++++++++ .../Design/components/Flex/Flex.tsx | 31 +++--- .../Design/components/Flex/Flex.type.ts | 3 +- .../Design/components/Icon/Icon.style.ts | 1 + .../Design/components/Icon/Icon.tsx | 2 + .../Design/components/Icon/Icon.type.ts | 3 +- .../Design/components/Title/Title.style.ts | 12 ++- .../Design/components/Title/Title.tsx | 5 +- .../Design/components/Title/Title.type.ts | 1 + client/src/components/StepList/Step.tsx | 2 +- client/src/constants/queryKeys.ts | 1 + client/src/constants/routerUrls.ts | 6 +- .../queries/event/useRequestPostEvent.ts | 3 + .../queries/images/useRequestDeleteImages.ts | 23 +++++ .../queries/images/useRequestGetImages.ts | 20 ++++ .../queries/images/useRequestPostImages.ts | 23 +++++ client/src/hooks/useAddImagesPage.ts | 96 +++++++++++++++++++ .../src/pages/AddImagesPage/AddImagesPage.tsx | 56 +++++++++++ .../pages/EventPage/AdminPage/AdminPage.tsx | 7 +- .../src/pages/EventPage/HomePage/HomePage.tsx | 20 +++- client/src/pages/ImagesPage/ImagesPage.tsx | 49 ++++++++++ client/src/router.tsx | 10 ++ client/src/types/serviceType.ts | 9 ++ client/src/utils/udpateMetaTag.ts | 11 +++ 34 files changed, 673 insertions(+), 31 deletions(-) create mode 100644 client/src/apis/request/images.ts create mode 100644 client/src/assets/image/photoButton.svg create mode 100644 client/src/components/Design/components/Carousel/Carousel.stories.tsx create mode 100644 client/src/components/Design/components/Carousel/Carousel.style.ts create mode 100644 client/src/components/Design/components/Carousel/Carousel.tsx create mode 100644 client/src/components/Design/components/Carousel/Carousel.type.ts create mode 100644 client/src/components/Design/components/Carousel/CarouselDeleteButton.tsx create mode 100644 client/src/components/Design/components/Carousel/CarouselIndicator.tsx create mode 100644 client/src/components/Design/components/Carousel/useCarousel.tsx create mode 100644 client/src/hooks/queries/images/useRequestDeleteImages.ts create mode 100644 client/src/hooks/queries/images/useRequestGetImages.ts create mode 100644 client/src/hooks/queries/images/useRequestPostImages.ts create mode 100644 client/src/hooks/useAddImagesPage.ts create mode 100644 client/src/pages/AddImagesPage/AddImagesPage.tsx create mode 100644 client/src/pages/ImagesPage/ImagesPage.tsx create mode 100644 client/src/utils/udpateMetaTag.ts diff --git a/client/src/GlobalStyle.ts b/client/src/GlobalStyle.ts index b25bb94d1..b2eca3474 100644 --- a/client/src/GlobalStyle.ts +++ b/client/src/GlobalStyle.ts @@ -145,4 +145,10 @@ export const GlobalStyle = css` max-width: 768px; margin: 0 auto; } + + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + &::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ + } `; diff --git a/client/src/apis/baseUrl.ts b/client/src/apis/baseUrl.ts index 37fc00a94..c425dc40f 100644 --- a/client/src/apis/baseUrl.ts +++ b/client/src/apis/baseUrl.ts @@ -1,3 +1,4 @@ export const BASE_URL = { HD: process.env.API_BASE_URL, + S3: process.env.S3_URL, }; diff --git a/client/src/apis/request/images.ts b/client/src/apis/request/images.ts new file mode 100644 index 000000000..b881ee3ac --- /dev/null +++ b/client/src/apis/request/images.ts @@ -0,0 +1,51 @@ +import {EventId, Images} from 'types/serviceType'; + +import {BASE_URL} from '@apis/baseUrl'; +import {ADMIN_API_PREFIX, USER_API_PREFIX} from '@apis/endpointPrefix'; +import {requestDelete, requestGet, requestPostWithoutResponse} from '@apis/fetcher'; +import {WithEventId} from '@apis/withId.type'; + +export interface RequestPostImages { + formData: FormData; +} + +export const requestPostImages = async ({eventId, formData}: WithEventId<RequestPostImages>) => { + // return await requestPostWithoutResponse({ + // baseUrl: BASE_URL.HD, + // endpoint: `${ADMIN_API_PREFIX}/${eventId}/images`, + // headers: { + // 'Content-Type': 'multipart/form-data', + // }, + // body: formData, + // }); + + // TODO: (@todari): 기존의 request 방식들은 기본적으로 + // header를 Content-Type : application/json 으로 보내주고 있음 + // multipart/form-data 요청을 보내기 위해선 header Content-Type을 빈 객체로 전달해야 함 + fetch(`${BASE_URL.HD}${ADMIN_API_PREFIX}/${eventId}/images`, { + credentials: 'include', + // headers: { + // 'Content-Type': 'multipart/form-data', + // }, + method: 'POST', + body: formData, + }); +}; + +export const requestGetImages = async ({eventId}: WithEventId) => { + return await requestGet<Images>({ + baseUrl: BASE_URL.HD, + endpoint: `${USER_API_PREFIX}/${eventId}/images`, + }); +}; + +export interface RequestDeleteImage { + imageId: number; +} + +export const requestDeleteImage = async ({eventId, imageId}: WithEventId<RequestDeleteImage>) => { + return await requestDelete({ + baseUrl: BASE_URL.HD, + endpoint: `${ADMIN_API_PREFIX}/${eventId}/images/${imageId}`, + }); +}; diff --git a/client/src/assets/image/photoButton.svg b/client/src/assets/image/photoButton.svg new file mode 100644 index 000000000..5db0eb482 --- /dev/null +++ b/client/src/assets/image/photoButton.svg @@ -0,0 +1,4 @@ +<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect opacity="0.24" width="36" height="36" rx="8" fill="#56555A"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M23.25 10.5H12.75C12.1533 10.5 11.581 10.7371 11.159 11.159C10.7371 11.581 10.5 12.1533 10.5 12.75V20.2905L11.943 19.2405C12.3971 18.9106 12.9449 18.7349 13.5062 18.7392C14.0675 18.7435 14.6126 18.9276 15.0615 19.2645L16.4535 20.3085L20.0055 17.2635C20.4938 16.8453 21.1187 16.6208 21.7615 16.6327C22.4043 16.6446 23.0205 16.892 23.493 17.328L25.5 19.1805V12.75C25.5 12.1533 25.2629 11.581 24.841 11.159C24.419 10.7371 23.8467 10.5 23.25 10.5ZM27.75 21.747V12.75C27.75 11.5565 27.2759 10.4119 26.432 9.56802C25.5881 8.72411 24.4435 8.25 23.25 8.25H12.75C11.5565 8.25 10.4119 8.72411 9.56802 9.56802C8.72411 10.4119 8.25 11.5565 8.25 12.75V23.25C8.25 24.4435 8.72411 25.5881 9.56802 26.432C10.4119 27.2759 11.5565 27.75 12.75 27.75H23.25C24.4435 27.75 25.5881 27.2759 26.432 26.432C27.2759 25.5881 27.75 24.4435 27.75 23.25V21.747ZM25.5 22.242L21.9675 18.981C21.9 18.9188 21.812 18.8835 21.7202 18.8819C21.6284 18.8802 21.5392 18.9123 21.4695 18.972L17.232 22.605L16.5465 23.193L15.825 22.6515L13.71 21.066C13.646 21.0183 13.5684 20.9923 13.4885 20.9917C13.4086 20.9912 13.3307 21.0162 13.266 21.063L10.5 23.0715V23.25C10.5 23.8467 10.7371 24.419 11.159 24.841C11.581 25.2629 12.1533 25.5 12.75 25.5H23.25C23.8467 25.5 24.419 25.2629 24.841 24.841C25.2629 24.419 25.5 23.8467 25.5 23.25V22.242ZM17.25 15C17.25 15.5967 17.0129 16.169 16.591 16.591C16.169 17.0129 15.5967 17.25 15 17.25C14.4033 17.25 13.831 17.0129 13.409 16.591C12.9871 16.169 12.75 15.5967 12.75 15C12.75 14.4033 12.9871 13.831 13.409 13.409C13.831 12.9871 14.4033 12.75 15 12.75C15.5967 12.75 16.169 12.9871 16.591 13.409C17.0129 13.831 17.25 14.4033 17.25 15Z" fill="white"/> +</svg> diff --git a/client/src/components/Design/components/Carousel/Carousel.stories.tsx b/client/src/components/Design/components/Carousel/Carousel.stories.tsx new file mode 100644 index 000000000..0a8b19930 --- /dev/null +++ b/client/src/components/Design/components/Carousel/Carousel.stories.tsx @@ -0,0 +1,27 @@ +import type {Meta, StoryObj} from '@storybook/react'; + +import Carousel from './Carousel'; + +const meta = { + title: 'Components/Carousel', + component: Carousel, + tags: ['autodocs'], + parameters: { + layout: 'centered', + width: 430, + }, + argTypes: {}, + args: { + urls: [ + 'https://wooteco-crew-wiki.s3.ap-northeast-2.amazonaws.com/%EC%BF%A0%ED%82%A4(6%EA%B8%B0)/image.png', + 'https://wooteco-crew-wiki.s3.ap-northeast-2.amazonaws.com/%EC%BF%A0%ED%82%A4%286%EA%B8%B0%29/4tyq1x19rsn.jpg', + 'https://img.danawa.com/images/descFiles/5/896/4895281_1_16376712347542321.gif', + ], + }, +} satisfies Meta<typeof Carousel>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const Playground: Story = {}; diff --git a/client/src/components/Design/components/Carousel/Carousel.style.ts b/client/src/components/Design/components/Carousel/Carousel.style.ts new file mode 100644 index 000000000..addc2926b --- /dev/null +++ b/client/src/components/Design/components/Carousel/Carousel.style.ts @@ -0,0 +1,94 @@ +import {css} from '@emotion/react'; + +import {Theme} from '@components/Design/theme/theme.type'; + +export const carouselWrapperStyle = css` + position: relative; + overflow: hidden; + display: flex; +`; + +interface ImageCardContainerStyleProps { + currentIndex: number; + length: number; + translateX: number; + isDragging: boolean; +} + +export const imageCardContainerStyle = ({ + currentIndex, + length, + translateX, + isDragging, +}: ImageCardContainerStyleProps) => css` + display: flex; + gap: 1rem; + margin-inline: 2rem; + transform: translateX( + calc( + (100vw - 3rem) * ${-currentIndex} + + ${(currentIndex === 0 && translateX > 0) || (currentIndex === length - 1 && translateX < 0) ? 0 : translateX}px + ) + ); + transition: ${isDragging ? 'none' : '0.2s'}; + transition-timing-function: cubic-bezier(0.7, 0.62, 0.62, 1.16); +`; + +interface ImageCardStyleProps { + theme: Theme; +} + +export const imageCardStyle = ({theme}: ImageCardStyleProps) => css` + position: relative; + display: flex; + justify-content: center; + align-items: center; + clip-path: inset(0 round 1rem); + background-color: ${theme.colors.gray}; +`; + +export const imageStyle = css` + width: calc(100vw - 4rem); + aspect-ratio: 3/4; + object-fit: contain; +`; + +export const deleteButtonStyle = css` + position: absolute; + top: 1rem; + right: 1rem; + padding: 0.5rem; + opacity: 0.48; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; +`; + +export const indicatorContainerStyle = css` + position: absolute; + left: 50%; + bottom: 1rem; + transform: translateX(-50%); + display: flex; + gap: 0.25rem; + width: 8rem; +`; + +interface IndicatorStyleProps { + index: number; + currentIndex: number; + theme: Theme; +} + +export const indicatorStyle = ({index, currentIndex, theme}: IndicatorStyleProps) => css` + width: 100%; + height: 0.125rem; + border-radius: 0.0625rem; + opacity: ${index !== currentIndex ? 0.48 : 1}; + background-color: ${index !== currentIndex ? theme.colors.lightGrayContainer : theme.colors.primary}; + transition: 0.2s; + transition-timing-function: cubic-bezier(0.7, 0.62, 0.62, 1.16); + content: ' '; +`; diff --git a/client/src/components/Design/components/Carousel/Carousel.tsx b/client/src/components/Design/components/Carousel/Carousel.tsx new file mode 100644 index 000000000..9b7b00bad --- /dev/null +++ b/client/src/components/Design/components/Carousel/Carousel.tsx @@ -0,0 +1,36 @@ +/** @jsxImportSource @emotion/react */ +import {CarouselProps} from './Carousel.type'; +import CarouselIndicator from './CarouselIndicator'; +import CarouselDeleteButton from './CarouselDeleteButton'; +import {carouselWrapperStyle, imageCardContainerStyle, imageCardStyle, imageStyle} from './Carousel.style'; +import useCarousel from './useCarousel'; + +const Carousel = ({urls, onClickDelete}: CarouselProps) => { + const {handleDragStart, handleDrag, handleDragEnd, theme, currentIndex, translateX, isDragging, handleClickDelete} = + useCarousel({urls, onClickDelete}); + + return ( + <div css={carouselWrapperStyle}> + <div + css={imageCardContainerStyle({currentIndex, length: urls.length, translateX, isDragging})} + onMouseDown={handleDragStart} + onMouseMove={handleDrag} + onMouseUp={handleDragEnd} + onTouchStart={handleDragStart} + onTouchMove={handleDrag} + onTouchEnd={handleDragEnd} + > + {urls && + urls.map((url, index) => ( + <div key={url} css={imageCardStyle({theme})}> + <img src={url} alt={`업로드된 이미지 ${index + 1}`} css={imageStyle} /> + {onClickDelete && <CarouselDeleteButton onClick={() => handleClickDelete(index)} />} + </div> + ))} + </div> + {urls.length !== 1 && <CarouselIndicator length={urls.length} currentIndex={currentIndex} />} + </div> + ); +}; + +export default Carousel; diff --git a/client/src/components/Design/components/Carousel/Carousel.type.ts b/client/src/components/Design/components/Carousel/Carousel.type.ts new file mode 100644 index 000000000..746ace1f2 --- /dev/null +++ b/client/src/components/Design/components/Carousel/Carousel.type.ts @@ -0,0 +1,4 @@ +export interface CarouselProps { + urls: string[]; + onClickDelete?: (index: number) => void; +} diff --git a/client/src/components/Design/components/Carousel/CarouselDeleteButton.tsx b/client/src/components/Design/components/Carousel/CarouselDeleteButton.tsx new file mode 100644 index 000000000..9f7154395 --- /dev/null +++ b/client/src/components/Design/components/Carousel/CarouselDeleteButton.tsx @@ -0,0 +1,17 @@ +import Icon from '../Icon/Icon'; + +import {deleteButtonStyle} from './Carousel.style'; + +interface Props { + onClick: () => void; +} + +const CarouselDeleteButton = ({onClick}: Props) => { + return ( + <button css={deleteButtonStyle} onClick={onClick}> + <Icon iconType="x" /> + </button> + ); +}; + +export default CarouselDeleteButton; diff --git a/client/src/components/Design/components/Carousel/CarouselIndicator.tsx b/client/src/components/Design/components/Carousel/CarouselIndicator.tsx new file mode 100644 index 000000000..971fa7d3e --- /dev/null +++ b/client/src/components/Design/components/Carousel/CarouselIndicator.tsx @@ -0,0 +1,22 @@ +import {useTheme} from '@components/Design/theme/HDesignProvider'; + +import {indicatorContainerStyle, indicatorStyle} from './Carousel.style'; + +interface Props { + length: number; + currentIndex: number; +} + +const CarouselIndicator = ({length, currentIndex}: Props) => { + const {theme} = useTheme(); + + return ( + <div css={indicatorContainerStyle}> + {Array.from({length}).map((_, index) => ( + <div key={index} css={indicatorStyle({index, currentIndex, theme})} /> + ))} + </div> + ); +}; + +export default CarouselIndicator; diff --git a/client/src/components/Design/components/Carousel/useCarousel.tsx b/client/src/components/Design/components/Carousel/useCarousel.tsx new file mode 100644 index 000000000..73cb1d0f6 --- /dev/null +++ b/client/src/components/Design/components/Carousel/useCarousel.tsx @@ -0,0 +1,48 @@ +import {useRef, useState} from 'react'; + +import {useTheme} from '@components/Design/theme/HDesignProvider'; + +import {CarouselProps} from './Carousel.type'; + +const useCarousel = ({urls, onClickDelete}: CarouselProps) => { + const startX = useRef(0); + const [translateX, setTranslateX] = useState(0); + const [isDragging, setIsDragging] = useState(false); + const [currentIndex, setCurrentIndex] = useState(0); + const {theme} = useTheme(); + + const handleDragStart = (e: React.TouchEvent | React.MouseEvent) => { + setIsDragging(true); + startX.current = 'touches' in e ? e.touches[0].clientX : e.clientX; + }; + + const handleDrag = (e: React.TouchEvent | React.MouseEvent) => { + if (!isDragging) return; + const currentX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const deltaX = currentX - startX.current; + setTranslateX(deltaX); + }; + + const threshold = window.screen.width / 10; + + const handleDragEnd = () => { + setIsDragging(false); + if (-translateX > threshold) { + setCurrentIndex(prev => (prev !== urls.length - 1 ? prev + 1 : prev)); + } + if (+translateX > threshold) { + setCurrentIndex(prev => (prev !== 0 ? prev - 1 : prev)); + } + setTranslateX(0); + }; + + const handleClickDelete = (index: number) => { + if (!onClickDelete) return; + onClickDelete(index); + if (urls.length !== 1 && index === urls.length - 1) setCurrentIndex(prev => prev - 1); + }; + + return {handleDragStart, handleDrag, handleDragEnd, theme, currentIndex, translateX, isDragging, handleClickDelete}; +}; + +export default useCarousel; diff --git a/client/src/components/Design/components/Flex/Flex.tsx b/client/src/components/Design/components/Flex/Flex.tsx index 87cddbb01..c79bfeb07 100644 --- a/client/src/components/Design/components/Flex/Flex.tsx +++ b/client/src/components/Design/components/Flex/Flex.tsx @@ -29,20 +29,23 @@ const Flex = forwardRef<HTMLDivElement, StrictPropsWithChildren<FlexProps>>(({ch return ( <div ref={ref} - css={[flexStyle({ - theme, - justifyContent, - alignItems, - flexDirection, - gap, - padding, - paddingInline, - margin, - width, - height, - backgroundColor, - minHeight, - }), cssProp]} + css={[ + flexStyle({ + theme, + justifyContent, + alignItems, + flexDirection, + gap, + padding, + paddingInline, + margin, + width, + height, + backgroundColor, + minHeight, + }), + cssProp, + ]} {...htmlProps} > {children} diff --git a/client/src/components/Design/components/Flex/Flex.type.ts b/client/src/components/Design/components/Flex/Flex.type.ts index ec393e292..d7dc18de2 100644 --- a/client/src/components/Design/components/Flex/Flex.type.ts +++ b/client/src/components/Design/components/Flex/Flex.type.ts @@ -21,5 +21,4 @@ export type FlexProps = React.HTMLAttributes<HTMLDivElement> & { minHeight?: string; cssProp?: CSSObject; -} - +}; diff --git a/client/src/components/Design/components/Icon/Icon.style.ts b/client/src/components/Design/components/Icon/Icon.style.ts index 43620715b..5217c8295 100644 --- a/client/src/components/Design/components/Icon/Icon.style.ts +++ b/client/src/components/Design/components/Icon/Icon.style.ts @@ -20,6 +20,7 @@ const ICON_DEFAULT_COLOR: Record<IconType, IconColor> = { meatballs: 'black', editPencil: 'gray', heundeut: 'gray', + photoButton: 'white', }; export const iconStyle = ({iconType, theme, iconColor}: IconStylePropsWithTheme) => { diff --git a/client/src/components/Design/components/Icon/Icon.tsx b/client/src/components/Design/components/Icon/Icon.tsx index fcedcdde7..8594472ca 100644 --- a/client/src/components/Design/components/Icon/Icon.tsx +++ b/client/src/components/Design/components/Icon/Icon.tsx @@ -14,6 +14,7 @@ import X from '@assets/image/x.svg'; import PencilMini from '@assets/image/pencil_mini.svg'; import Meatballs from '@assets/image/meatballs.svg'; import EditPencil from '@assets/image/editPencil.svg'; +import PhotoButton from '@assets/image/photoButton.svg'; import {IconProps} from '@HDcomponents/Icon/Icon.type'; import {useTheme} from '@theme/HDesignProvider'; @@ -35,6 +36,7 @@ const ICON = { meatballs: <Meatballs />, editPencil: <EditPencil />, heundeut: <img src={`${process.env.IMAGE_URL}/heundeut.svg`} />, + photoButton: <PhotoButton />, }; export const Icon: React.FC<IconProps> = ({iconColor, iconType, ...htmlProps}: IconProps) => { diff --git a/client/src/components/Design/components/Icon/Icon.type.ts b/client/src/components/Design/components/Icon/Icon.type.ts index 4d92e73b5..1658868df 100644 --- a/client/src/components/Design/components/Icon/Icon.type.ts +++ b/client/src/components/Design/components/Icon/Icon.type.ts @@ -16,7 +16,8 @@ export type IconType = | 'toss' | 'meatballs' | 'editPencil' - | 'heundeut'; + | 'heundeut' + | 'photoButton'; export type IconColor = ColorKeys; diff --git a/client/src/components/Design/components/Title/Title.style.ts b/client/src/components/Design/components/Title/Title.style.ts index bd19002d1..b5ce8a257 100644 --- a/client/src/components/Design/components/Title/Title.style.ts +++ b/client/src/components/Design/components/Title/Title.style.ts @@ -13,11 +13,13 @@ export const titleStyle = (theme: Theme) => borderRadius: '0.75rem', }); -export const titleContainerStyle = css({ - display: 'flex', - justifyContent: 'space-between', - paddingLeft: '0.5rem', -}); +export const titleContainerStyle = (hasDropdown: boolean) => + css({ + display: 'flex', + justifyContent: 'space-between', + paddingLeft: '0.5rem', + paddingRight: hasDropdown ? '0' : '0.5rem', + }); export const amountContainerStyle = css({ display: 'flex', diff --git a/client/src/components/Design/components/Title/Title.tsx b/client/src/components/Design/components/Title/Title.tsx index deab3ec4a..ebab52e56 100644 --- a/client/src/components/Design/components/Title/Title.tsx +++ b/client/src/components/Design/components/Title/Title.tsx @@ -6,12 +6,13 @@ import {useTheme} from '@theme/HDesignProvider'; import Amount from '../Amount/Amount'; -export const Title: React.FC<TitleProps> = ({title, amount, dropdown}: TitleProps) => { +export const Title: React.FC<TitleProps> = ({title, amount, icon, dropdown}: TitleProps) => { const {theme} = useTheme(); return ( <div css={titleStyle(theme)}> - <div css={titleContainerStyle}> + <div css={titleContainerStyle(!!dropdown)}> <Text size="subTitle">{title}</Text> + {icon} {dropdown} </div> <div css={amountContainerStyle}> diff --git a/client/src/components/Design/components/Title/Title.type.ts b/client/src/components/Design/components/Title/Title.type.ts index cb28743f4..1e07610d2 100644 --- a/client/src/components/Design/components/Title/Title.type.ts +++ b/client/src/components/Design/components/Title/Title.type.ts @@ -3,6 +3,7 @@ export interface TitleStyleProps {} export interface TitleCustomProps { title: string; amount?: number; + icon?: React.ReactNode; dropdown?: React.ReactNode; } diff --git a/client/src/components/StepList/Step.tsx b/client/src/components/StepList/Step.tsx index 997cf03c7..aead2f37c 100644 --- a/client/src/components/StepList/Step.tsx +++ b/client/src/components/StepList/Step.tsx @@ -20,7 +20,7 @@ const Step = ({step, isAdmin}: Prop) => { const navigate = useNavigate(); const eventId = getEventIdByUrl(); const handleClickStep = (bill: Bill) => { - if (isAdmin) navigate(`/event/${eventId}/edit-bill`, {state: {bill}}); + if (isAdmin) navigate(`/event/${eventId}/admin/edit-bill`, {state: {bill}}); }; return ( diff --git a/client/src/constants/queryKeys.ts b/client/src/constants/queryKeys.ts index b0835cfd5..0c7d4e50b 100644 --- a/client/src/constants/queryKeys.ts +++ b/client/src/constants/queryKeys.ts @@ -5,6 +5,7 @@ const QUERY_KEYS = { currentMembers: 'currentMembers', reports: 'reports', billDetails: 'billDetails', + images: 'images', }; export default QUERY_KEYS; diff --git a/client/src/constants/routerUrls.ts b/client/src/constants/routerUrls.ts index 535e9d825..e4cecc9d5 100644 --- a/client/src/constants/routerUrls.ts +++ b/client/src/constants/routerUrls.ts @@ -5,8 +5,10 @@ export const ROUTER_URLS = { eventManage: '/event/:eventId/admin', home: '/event/:eventId/home', member: '/event/:eventId/admin/member', - addBill: '/event/:eventId/add-bill', - editBill: '/event/:eventId/edit-bill', + addBill: '/event/:eventId/admin/add-bill', + editBill: '/event/:eventId/admin/edit-bill', eventEdit: 'event/:eventId/admin/edit', + images: '/event/:eventId/images', + addImages: '/event/:eventId/admin/add-images', send: 'event/:eventId/:memberId/send', }; diff --git a/client/src/hooks/queries/event/useRequestPostEvent.ts b/client/src/hooks/queries/event/useRequestPostEvent.ts index 0f30b48e1..5fbf37bdb 100644 --- a/client/src/hooks/queries/event/useRequestPostEvent.ts +++ b/client/src/hooks/queries/event/useRequestPostEvent.ts @@ -2,8 +2,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'; import {RequestPostEvent, requestPostEvent} from '@apis/request/event'; +import QUERY_KEYS from '@constants/queryKeys'; + const useRequestPostEvent = () => { const queryClient = useQueryClient(); + const {mutate, mutateAsync, ...rest} = useMutation({ mutationFn: ({eventName, password}: RequestPostEvent) => requestPostEvent({eventName, password}), onSuccess: () => { diff --git a/client/src/hooks/queries/images/useRequestDeleteImages.ts b/client/src/hooks/queries/images/useRequestDeleteImages.ts new file mode 100644 index 000000000..a6ecc49ad --- /dev/null +++ b/client/src/hooks/queries/images/useRequestDeleteImages.ts @@ -0,0 +1,23 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; + +import {requestDeleteImage, RequestDeleteImage} from '@apis/request/images'; + +import getEventIdByUrl from '@utils/getEventIdByUrl'; + +import QUERY_KEYS from '@constants/queryKeys'; + +const useRequestDeleteImage = () => { + const eventId = getEventIdByUrl(); + const queryClient = useQueryClient(); + + const {mutate, ...rest} = useMutation({ + mutationFn: ({imageId}: RequestDeleteImage) => requestDeleteImage({eventId, imageId}), + onSuccess: () => { + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.images]}); + }, + }); + + return {deleteImage: mutate, ...rest}; +}; + +export default useRequestDeleteImage; diff --git a/client/src/hooks/queries/images/useRequestGetImages.ts b/client/src/hooks/queries/images/useRequestGetImages.ts new file mode 100644 index 000000000..e976660f1 --- /dev/null +++ b/client/src/hooks/queries/images/useRequestGetImages.ts @@ -0,0 +1,20 @@ +import {useQuery} from '@tanstack/react-query'; + +import {requestGetImages} from '@apis/request/images'; + +import getEventIdByUrl from '@utils/getEventIdByUrl'; + +import QUERY_KEYS from '@constants/queryKeys'; + +const useRequestGetImages = () => { + const eventId = getEventIdByUrl(); + + const {data, ...rest} = useQuery({ + queryKey: [QUERY_KEYS.images], + queryFn: () => requestGetImages({eventId}), + }); + + return {images: data?.images ?? [], ...rest}; +}; + +export default useRequestGetImages; diff --git a/client/src/hooks/queries/images/useRequestPostImages.ts b/client/src/hooks/queries/images/useRequestPostImages.ts new file mode 100644 index 000000000..619989694 --- /dev/null +++ b/client/src/hooks/queries/images/useRequestPostImages.ts @@ -0,0 +1,23 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; + +import {requestPostImages, RequestPostImages} from '@apis/request/images'; + +import getEventIdByUrl from '@utils/getEventIdByUrl'; + +import QUERY_KEYS from '@constants/queryKeys'; + +const useRequestPostImages = () => { + const eventId = getEventIdByUrl(); + const queryClient = useQueryClient(); + + const {mutate, ...rest} = useMutation({ + mutationFn: ({formData}: RequestPostImages) => requestPostImages({eventId, formData}), + onSuccess: () => { + queryClient.invalidateQueries({queryKey: [QUERY_KEYS.images]}); + }, + }); + + return {postImages: mutate, ...rest}; +}; + +export default useRequestPostImages; diff --git a/client/src/hooks/useAddImagesPage.ts b/client/src/hooks/useAddImagesPage.ts new file mode 100644 index 000000000..0bd45f490 --- /dev/null +++ b/client/src/hooks/useAddImagesPage.ts @@ -0,0 +1,96 @@ +import {useEffect, useRef, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; + +import {ImageFile} from 'types/serviceType'; + +import getEventIdByUrl from '@utils/getEventIdByUrl'; + +import useRequestGetImages from './queries/images/useRequestGetImages'; +import useRequestPostImages from './queries/images/useRequestPostImages'; +import useRequestDeleteImage from './queries/images/useRequestDeleteImages'; + +type LoadedImage = ImageFile; +type AddedImage = File; + +const useAddImagesPage = () => { + const [images, setImages] = useState<Array<LoadedImage | AddedImage>>([]); + const [isPrevImageDeleted, setIsPrevImageDeleted] = useState(false); + const addedImages = images.filter(image => image instanceof File); + const {images: prevImages} = useRequestGetImages(); + const urls = images.map(image => { + if (image instanceof File) { + return URL.createObjectURL(image); + } else { + return image.url; + } + }); + + const fileInputRef = useRef<HTMLInputElement>(null); + const navigate = useNavigate(); + const eventId = getEventIdByUrl(); + + const {postImages, isPending, isSuccess: isSuccessPostImage} = useRequestPostImages(); + const {deleteImage} = useRequestDeleteImage(); + + useEffect(() => { + if (!prevImages) return; + setImages([...prevImages]); + }, [prevImages]); + + const handleChangeImages = (event: React.ChangeEvent<HTMLInputElement>) => { + if (event.target.files) { + const dataTransfer = new DataTransfer(); + + if (addedImages) { + Array.from(addedImages).forEach(image => dataTransfer.items.add(image)); + } + + Array.from(event.target.files).forEach(image => dataTransfer.items.add(image)); + + setImages([...prevImages, ...dataTransfer.files]); + } + }; + + const handleDeleteImage = (index: number) => { + if ('url' in images[index]) { + //TODO: (@Todari): 추후 낙관적 업데이트 적용 + deleteImage({ + imageId: images[index].id, + }); + setIsPrevImageDeleted(false); + } else { + setImages(prev => prev.filter((_, idx) => idx !== index)); + } + }; + + const canSubmit = !!addedImages || isPrevImageDeleted; + + const submitImages = () => { + const formData = new FormData(); + + if (!addedImages) return; + + for (let i = 0; i < addedImages.length; i++) { + formData.append('images', addedImages[i], addedImages[i].name); + } + + postImages({formData}); + }; + + useEffect(() => { + document.body.style.overflowX = 'hidden'; + + return () => { + document.body.style.overflowX = 'auto'; + }; + }, []); + + useEffect(() => { + if (!isSuccessPostImage) return; + navigate(`/event/${eventId}/admin`); + }, [isSuccessPostImage]); + + return {fileInputRef, handleChangeImages, urls, handleDeleteImage, isPending, canSubmit, submitImages}; +}; + +export default useAddImagesPage; diff --git a/client/src/pages/AddImagesPage/AddImagesPage.tsx b/client/src/pages/AddImagesPage/AddImagesPage.tsx new file mode 100644 index 000000000..60c87a4d9 --- /dev/null +++ b/client/src/pages/AddImagesPage/AddImagesPage.tsx @@ -0,0 +1,56 @@ +import {css} from '@emotion/react'; + +import Carousel from '@components/Design/components/Carousel/Carousel'; + +import useAddImagesPage from '@hooks/useAddImagesPage'; + +import {Button, FixedButton, MainLayout, Top, TopNav} from '@components/Design'; + +const AddImagesPage = () => { + const {fileInputRef, handleChangeImages, urls, handleDeleteImage, isPending, canSubmit, submitImages} = + useAddImagesPage(); + + return ( + <MainLayout backgroundColor="white"> + <TopNav> + <TopNav.Item displayName="뒤로가기" noEmphasis routePath="-1" /> + </TopNav> + <div + css={css` + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + `} + > + <Top> + <Top.Line text="행사와 관련된" /> + <Top.Line text="사진들을 첨부해 주세요" emphasize={['사진들']} /> + </Top> + <input + ref={fileInputRef} + type="file" + accept="image/*" + multiple + onChange={handleChangeImages} + style={{display: 'none'}} + /> + <Button variants="primary" onClick={() => fileInputRef.current?.click()}> + 사진 추가하기 + </Button> + </div> + <Carousel urls={urls} onClickDelete={handleDeleteImage} /> + <div + css={css` + height: 9.25rem; + content: ' '; + `} + /> + <FixedButton variants={isPending ? 'loading' : 'primary'} disabled={!canSubmit} onClick={submitImages}> + 변경 완료 + </FixedButton> + </MainLayout> + ); +}; + +export default AddImagesPage; diff --git a/client/src/pages/EventPage/AdminPage/AdminPage.tsx b/client/src/pages/EventPage/AdminPage/AdminPage.tsx index da4a11747..0b3b1c79d 100644 --- a/client/src/pages/EventPage/AdminPage/AdminPage.tsx +++ b/client/src/pages/EventPage/AdminPage/AdminPage.tsx @@ -22,8 +22,12 @@ const AdminPage = () => { navigate(`/event/${eventId}/admin/member`); }; + const navigateAddImages = () => { + navigate(`/event/${eventId}/admin/add-images`); + }; + const navigateAddBill = () => { - navigate(`/event/${eventId}/add-bill`); + navigate(`/event/${eventId}/admin/add-bill`); }; return ( @@ -35,6 +39,7 @@ const AdminPage = () => { <Dropdown> <DropdownButton text="전체 참여자 관리" onClick={navigateEventMemberManage} /> <DropdownButton text="계좌번호 입력하기" onClick={navigateAccountInputPage} /> + <DropdownButton text="사진 첨부하기" onClick={navigateAddImages} /> </Dropdown> } /> diff --git a/client/src/pages/EventPage/HomePage/HomePage.tsx b/client/src/pages/EventPage/HomePage/HomePage.tsx index 1876327bf..8cf15bc39 100644 --- a/client/src/pages/EventPage/HomePage/HomePage.tsx +++ b/client/src/pages/EventPage/HomePage/HomePage.tsx @@ -1,14 +1,17 @@ import type {EventPageContextProps} from '../EventPageLayout'; -import {useOutletContext} from 'react-router-dom'; +import {useNavigate, useOutletContext} from 'react-router-dom'; import StepList from '@components/StepList/Steps'; import useRequestGetSteps from '@hooks/queries/step/useRequestGetSteps'; import Reports from '@components/Reports/Reports'; +import useRequestGetImages from '@hooks/queries/images/useRequestGetImages'; import {useTotalExpenseAmountStore} from '@store/totalExpenseAmountStore'; -import {Tab, Tabs, Title} from '@HDesign/index'; +import {Icon, Tab, Tabs, Title} from '@HDesign/index'; + +import getEventIdByUrl from '@utils/getEventIdByUrl'; import {receiptStyle} from './HomePage.style'; @@ -16,10 +19,21 @@ const HomePage = () => { const {isAdmin, eventName} = useOutletContext<EventPageContextProps>(); const {steps} = useRequestGetSteps(); const {totalExpenseAmount} = useTotalExpenseAmountStore(); + const {images} = useRequestGetImages(); + const navigate = useNavigate(); + const eventId = getEventIdByUrl(); return ( <div css={receiptStyle}> - <Title title={eventName} amount={totalExpenseAmount} /> + <Title + title={eventName} + amount={totalExpenseAmount} + icon={ + <button> + <Icon iconType="photoButton" onClick={() => navigate(`/event/${eventId}/images`)} /> + </button> + } + /> <Tabs> <Tab label="참여자 별 정산" content={<Reports />} /> <Tab label="전체 지출 내역" content={<StepList data={steps ?? []} isAdmin={isAdmin} />} /> diff --git a/client/src/pages/ImagesPage/ImagesPage.tsx b/client/src/pages/ImagesPage/ImagesPage.tsx new file mode 100644 index 000000000..8393ca8b9 --- /dev/null +++ b/client/src/pages/ImagesPage/ImagesPage.tsx @@ -0,0 +1,49 @@ +import {css} from '@emotion/react'; +import {useEffect} from 'react'; + +import Carousel from '@components/Design/components/Carousel/Carousel'; +import useRequestGetImages from '@hooks/queries/images/useRequestGetImages'; + +import {MainLayout, Top, TopNav} from '@components/Design'; + +const ImagesPage = () => { + const {images} = useRequestGetImages(); + + useEffect(() => { + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = 'auto'; + }; + }, []); + + return ( + <MainLayout backgroundColor="white"> + <TopNav> + <TopNav.Item displayName="뒤로가기" noEmphasis routePath="-1" /> + </TopNav> + <div + css={css` + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + `} + > + <Top> + <Top.Line text="행사와 관련된" /> + <Top.Line text="사진들을 확인하세요" emphasize={['사진들']} /> + </Top> + </div> + <Carousel urls={images.map(({url}) => url)} /> + <div + css={css` + height: 9.25rem; + content: ' '; + `} + /> + </MainLayout> + ); +}; + +export default ImagesPage; diff --git a/client/src/router.tsx b/client/src/router.tsx index a5d444897..9969ce139 100644 --- a/client/src/router.tsx +++ b/client/src/router.tsx @@ -22,6 +22,8 @@ const AddBillFunnel = lazy(() => import('@pages/AddBillFunnel/AddBillFunnel')); const EventMember = lazy(() => import('@pages/EventPage/AdminPage/EventMember')); const EditBillPage = lazy(() => import('@pages/EditBillPage/EditBillPage')); const Account = lazy(() => import('@pages/AccountPage/Account')); +const ImagesPage = lazy(() => import('@pages/ImagesPage/ImagesPage')); +const AddImagesPage = lazy(() => import('@pages/AddImagesPage/AddImagesPage')); const router = createBrowserRouter([ { @@ -77,6 +79,14 @@ const router = createBrowserRouter([ path: ROUTER_URLS.eventEdit, element: <Account />, }, + { + path: ROUTER_URLS.images, + element: <ImagesPage />, + }, + { + path: ROUTER_URLS.addImages, + element: <AddImagesPage />, + }, { path: ROUTER_URLS.send, element: <SendPage />, diff --git a/client/src/types/serviceType.ts b/client/src/types/serviceType.ts index a2ecca3aa..5e406cee4 100644 --- a/client/src/types/serviceType.ts +++ b/client/src/types/serviceType.ts @@ -65,3 +65,12 @@ export interface Report { export interface Reports { reports: Report[]; } + +export interface Images { + images: ImageFile[]; +} + +export interface ImageFile { + id: number; + url: string; +} diff --git a/client/src/utils/udpateMetaTag.ts b/client/src/utils/udpateMetaTag.ts new file mode 100644 index 000000000..37909878c --- /dev/null +++ b/client/src/utils/udpateMetaTag.ts @@ -0,0 +1,11 @@ +export const updateMetaTag = (name: string, content: string) => { + let metaTag = document.querySelector(`meta[property="${name}"]`); + + if (!metaTag) { + metaTag = document.createElement('meta'); + metaTag.setAttribute('property', name); + document.head.appendChild(metaTag); + } + + metaTag.setAttribute('content', content); +}; From f5df120f690521b1a4ab6b3dc17aa64636dcade2 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Thu, 10 Oct 2024 16:43:07 +0900 Subject: [PATCH 15/20] =?UTF-8?q?feat:=20=EC=B4=88=EB=8C=80=ED=95=98?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EB=A7=81=ED=81=AC=20=EA=B3=B5=EC=9C=A0?= =?UTF-8?q?=EC=99=80=20=EC=B9=B4=EC=B9=B4=EC=98=A4=ED=86=A1=EC=9D=84=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20(#719)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 공유버튼 모바일과 데탑 분리 * feat: 송금버튼 새로운 반영사항 적용 * feat: 계좌번호 없을 때 금액 복사 기능 추가 * test: prop 변경으로 인한 스토리북 변경 * feat: 계좌번호 없을 때 금액 복사되는 onCopy 추가 * feat: 다나까 -> 요 체로 변경 * feat: 송금기능 페이지 이동으로 인해 memberId 추가 * feat: 송금 페이지 라우트 * feat: 송금하기 버튼을 눌렀을 때 navigate state로 정보 전달 * feat: 송금 방법(복사, 카카오페이, 토스) 제공 * feat: 아래 => 토스열기 명시적으로 변경 * feat: Banner 컴포넌트 생성 * feat: 세션스토리지 util 추가 * feat: 계좌번호 입력 유도 기능 구현 * feat: Flex div prop도 받도록 설정 * feat: 돌아가는 Chevron 컴포넌트 생성 * feat: 바깥 클릭 시 실행되는 컴포넌트 생성 * feat: Select 컴포넌트 제작 * feat: 요구사항 변경으로 인한 송금 플로우 변경 * feat: Select 컴포넌트 제작 * design: 텍스트 색깔 변경 * style: export 정리 * refactor: ClickOutsideDetector 사용 * feat: dropdown base 미트볼, 버튼 두 개 지원 * style: dropdown list z-index 추가 * feat: 드랍다운 버튼을 클릭할 때 드랍다운 리스트가 닫히는 기능 구현 * feat: 모바일에서 링크 초대와 카카오톡 초대 분리 * feat: 공유 메시지 변경 --- .../Design/components/Dropdown/ButtonBase.tsx | 38 ++++++++++++ .../components/Dropdown/Dropdown.stories.tsx | 8 +++ .../components/Dropdown/Dropdown.style.ts | 59 +++++++++++++----- .../Design/components/Dropdown/Dropdown.tsx | 48 +++++++-------- .../components/Dropdown/Dropdown.type.ts | 5 ++ .../components/Dropdown/DropdownButton.tsx | 13 +++- .../components/Dropdown/MeatballBase.tsx | 39 ++++++++++++ .../Design/components/Dropdown/useDropdown.ts | 31 ++-------- client/src/components/Design/token/zIndex.ts | 5 +- .../DesktopShareEventButton.tsx | 33 +++++----- .../MobileShareEventButton.tsx | 28 ++++++--- client/src/hooks/useShareEvent.ts | 60 ++++--------------- .../src/pages/EventPage/EventPageLayout.tsx | 7 +-- 13 files changed, 225 insertions(+), 149 deletions(-) create mode 100644 client/src/components/Design/components/Dropdown/ButtonBase.tsx create mode 100644 client/src/components/Design/components/Dropdown/MeatballBase.tsx diff --git a/client/src/components/Design/components/Dropdown/ButtonBase.tsx b/client/src/components/Design/components/Dropdown/ButtonBase.tsx new file mode 100644 index 000000000..8c1c553d0 --- /dev/null +++ b/client/src/components/Design/components/Dropdown/ButtonBase.tsx @@ -0,0 +1,38 @@ +/** @jsxImportSource @emotion/react */ +import {useTheme} from '@components/Design/theme/HDesignProvider'; + +import Button from '../Button/Button'; +import Flex from '../Flex/Flex'; + +import {dropdownButtonBaseStyle} from './Dropdown.style'; +import {DropdownProps} from './Dropdown.type'; +import DropdownButton from './DropdownButton'; + +type ButtonBaseProps = DropdownProps & { + isOpen: boolean; + setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; + dropdownRef: React.RefObject<HTMLElement>; +}; + +const ButtonBase = ({isOpen, setIsOpen, dropdownRef, baseButtonText, children}: ButtonBaseProps) => { + const {theme} = useTheme(); + + return ( + <> + <Button variants="tertiary" size="small" onClick={() => setIsOpen(true)}> + {baseButtonText} + </Button> + {isOpen && ( + <section ref={dropdownRef}> + <Flex {...dropdownButtonBaseStyle(theme)}> + {children.map((button, index) => ( + <DropdownButton key={index} setIsOpen={setIsOpen} {...button.props} /> + ))} + </Flex> + </section> + )} + </> + ); +}; + +export default ButtonBase; diff --git a/client/src/components/Design/components/Dropdown/Dropdown.stories.tsx b/client/src/components/Design/components/Dropdown/Dropdown.stories.tsx index 2504ecae7..bf324f8f7 100644 --- a/client/src/components/Design/components/Dropdown/Dropdown.stories.tsx +++ b/client/src/components/Design/components/Dropdown/Dropdown.stories.tsx @@ -19,7 +19,15 @@ const meta = { </div> ), ], + argTypes: { + base: { + description: '', + control: {type: 'select'}, + options: ['meatballs', 'button'], + }, + }, args: { + baseButtonText: '정산 초대하기', children: [ <DropdownButton text="전체 참여자 관리" onClick={() => alert('전체 참여자 관리 클릭')} />, <DropdownButton text="계좌번호 입력하기" onClick={() => alert('계좌번호 입력하기 클릭')} />, diff --git a/client/src/components/Design/components/Dropdown/Dropdown.style.ts b/client/src/components/Design/components/Dropdown/Dropdown.style.ts index 0074a5be6..e26863620 100644 --- a/client/src/components/Design/components/Dropdown/Dropdown.style.ts +++ b/client/src/components/Design/components/Dropdown/Dropdown.style.ts @@ -4,21 +4,50 @@ import {Theme} from '@components/Design/theme/theme.type'; import {FlexProps} from '../Flex/Flex.type'; -export const dropdownStyle: FlexProps = { - flexDirection: 'column', - width: '12.5rem', - padding: '0.5rem', - paddingInline: '0.5rem', - gap: '0.25rem', - backgroundColor: 'white', - - cssProp: { - position: 'absolute', - top: '2rem', - right: '-1rem', - borderRadius: '0.75rem', - boxShadow: '2px 4px 16px 0 rgba(0, 0, 0, 0.08)', - }, +export const dropdownBaseStyle = css({ + position: 'relative', + + WebkitTapHighlightColor: 'transparent', +}); + +export const dropdownStyle = (theme: Theme): FlexProps => { + return { + flexDirection: 'column', + width: '12.5rem', + padding: '0.5rem', + paddingInline: '0.5rem', + gap: '0.25rem', + backgroundColor: 'white', + + cssProp: { + position: 'absolute', + top: '2rem', + right: '-1rem', + borderRadius: '0.75rem', + boxShadow: '2px 4px 16px 0 rgba(0, 0, 0, 0.08)', + zIndex: theme.zIndex.dropdownList, + }, + }; +}; + +export const dropdownButtonBaseStyle = (theme: Theme): FlexProps => { + return { + flexDirection: 'column', + width: '12.5rem', + padding: '0.5rem', + paddingInline: '0.5rem', + gap: '0.25rem', + backgroundColor: 'white', + + cssProp: { + position: 'absolute', + top: '2.5rem', + right: '-0.5rem', + borderRadius: '0.75rem', + boxShadow: '2px 4px 16px 0 rgba(0, 0, 0, 0.08)', + zIndex: theme.zIndex.dropdownList, + }, + }; }; export const dropdownButtonStyle = (theme: Theme) => diff --git a/client/src/components/Design/components/Dropdown/Dropdown.tsx b/client/src/components/Design/components/Dropdown/Dropdown.tsx index 63045dd56..ab5fe23e4 100644 --- a/client/src/components/Design/components/Dropdown/Dropdown.tsx +++ b/client/src/components/Design/components/Dropdown/Dropdown.tsx @@ -1,35 +1,33 @@ /** @jsxImportSource @emotion/react */ -import Icon from '../Icon/Icon'; -import IconButton from '../IconButton/IconButton'; -import Flex from '../Flex/Flex'; +import ClickOutsideDetector from '../ClickOutsideDetector'; import useDropdown from './useDropdown'; import {DropdownProps} from './Dropdown.type'; -import DropdownButton from './DropdownButton'; -import {dropdownStyle} from './Dropdown.style'; +import MeatballBase from './MeatballBase'; +import ButtonBase from './ButtonBase'; +import {dropdownBaseStyle} from './Dropdown.style'; -const Dropdown = ({children}: DropdownProps) => { - const {isOpen, openDropdown, meetBallsRef, dropdownRef} = useDropdown(); - const isDropdownOpen = isOpen && meetBallsRef.current; +const Dropdown = ({base = 'meatballs', baseButtonText, children}: DropdownProps) => { + const {isOpen, setIsOpen, baseRef, dropdownRef} = useDropdown(); + const isDropdownOpen = isOpen && !!baseRef.current; return ( - <IconButton - ref={meetBallsRef} - variants="none" - onClick={openDropdown} - style={{position: 'relative', WebkitTapHighlightColor: 'transparent'}} - > - <Icon iconType="meatballs" /> - {isDropdownOpen && ( - <section ref={dropdownRef}> - <Flex {...dropdownStyle}> - {children.map(button => ( - <DropdownButton {...button.props} /> - ))} - </Flex> - </section> - )} - </IconButton> + <ClickOutsideDetector targetRef={baseRef} onClickOutside={() => setIsOpen(false)}> + <div ref={baseRef} css={dropdownBaseStyle}> + {base === 'meatballs' && ( + <MeatballBase isOpen={isDropdownOpen} setIsOpen={setIsOpen} dropdownRef={dropdownRef} children={children} /> + )} + {base === 'button' && ( + <ButtonBase + isOpen={isDropdownOpen} + setIsOpen={setIsOpen} + dropdownRef={dropdownRef} + children={children} + baseButtonText={baseButtonText} + /> + )} + </div> + </ClickOutsideDetector> ); }; diff --git a/client/src/components/Design/components/Dropdown/Dropdown.type.ts b/client/src/components/Design/components/Dropdown/Dropdown.type.ts index 1b67bbc33..5d8c3855f 100644 --- a/client/src/components/Design/components/Dropdown/Dropdown.type.ts +++ b/client/src/components/Design/components/Dropdown/Dropdown.type.ts @@ -1,7 +1,12 @@ +export type DropdownBase = 'meatballs' | 'button'; + export type DropdownButtonProps = React.HTMLAttributes<HTMLButtonElement> & { text: string; + setIsOpen?: React.Dispatch<React.SetStateAction<boolean>>; // 내부에서 사용하기 위한 props 외부에서 넣어주지 말 것 }; export type DropdownProps = { + base?: DropdownBase; + baseButtonText?: string; children: React.ReactElement<DropdownButtonProps>[]; }; diff --git a/client/src/components/Design/components/Dropdown/DropdownButton.tsx b/client/src/components/Design/components/Dropdown/DropdownButton.tsx index 005a6042a..8e3981654 100644 --- a/client/src/components/Design/components/Dropdown/DropdownButton.tsx +++ b/client/src/components/Design/components/Dropdown/DropdownButton.tsx @@ -7,10 +7,19 @@ import Text from '../Text/Text'; import {dropdownButtonStyle} from './Dropdown.style'; import {DropdownButtonProps} from './Dropdown.type'; -const DropdownButton = ({text, ...buttonProps}: DropdownButtonProps) => { +const DropdownButton = ({text, onClick, setIsOpen, ...buttonProps}: DropdownButtonProps) => { const {theme} = useTheme(); + return ( - <button css={dropdownButtonStyle(theme)} {...buttonProps}> + <button + css={dropdownButtonStyle(theme)} + onClick={event => { + event.stopPropagation(); + if (onClick) onClick(event); + if (setIsOpen) setIsOpen(false); + }} + {...buttonProps} + > <Text size="body" color="black"> {text} </Text> diff --git a/client/src/components/Design/components/Dropdown/MeatballBase.tsx b/client/src/components/Design/components/Dropdown/MeatballBase.tsx new file mode 100644 index 000000000..77b7c6c0f --- /dev/null +++ b/client/src/components/Design/components/Dropdown/MeatballBase.tsx @@ -0,0 +1,39 @@ +/** @jsxImportSource @emotion/react */ +import {useTheme} from '@components/Design/theme/HDesignProvider'; + +import Flex from '../Flex/Flex'; +import Icon from '../Icon/Icon'; +import IconButton from '../IconButton/IconButton'; + +import {dropdownStyle} from './Dropdown.style'; +import {DropdownProps} from './Dropdown.type'; +import DropdownButton from './DropdownButton'; + +type MeatballBaseProps = DropdownProps & { + isOpen: boolean; + setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; + dropdownRef: React.RefObject<HTMLElement>; +}; + +const MeatballBase = ({isOpen, setIsOpen, dropdownRef, children}: MeatballBaseProps) => { + const {theme} = useTheme(); + + return ( + <> + <IconButton variants="none" onClick={() => setIsOpen(true)}> + <Icon iconType="meatballs" /> + </IconButton> + {isOpen && ( + <section ref={dropdownRef}> + <Flex {...dropdownStyle(theme)}> + {children.map((button, index) => ( + <DropdownButton key={index} setIsOpen={setIsOpen} {...button.props} /> + ))} + </Flex> + </section> + )} + </> + ); +}; + +export default MeatballBase; diff --git a/client/src/components/Design/components/Dropdown/useDropdown.ts b/client/src/components/Design/components/Dropdown/useDropdown.ts index f484c62a0..f11616c4a 100644 --- a/client/src/components/Design/components/Dropdown/useDropdown.ts +++ b/client/src/components/Design/components/Dropdown/useDropdown.ts @@ -1,37 +1,14 @@ -import {useEffect, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; const useDropdown = () => { const [isOpen, setIsOpen] = useState(false); - const meetBallsRef = useRef<HTMLButtonElement>(null); + const baseRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLElement>(null); - const openDropdown = () => { - setIsOpen(true); - }; - - useEffect(() => { - const clickOutSide = (event: MouseEvent) => { - const targetNode = event.target as Node; - - if ( - (dropdownRef.current && dropdownRef.current.contains(targetNode)) || - (meetBallsRef.current && meetBallsRef.current.contains(targetNode)) - ) { - return; - } - - setIsOpen(false); - }; - document.addEventListener('mousedown', clickOutSide); - return () => { - document.removeEventListener('mousedown', clickOutSide); - }; - }, [dropdownRef]); - return { isOpen, - openDropdown, - meetBallsRef, + setIsOpen, + baseRef, dropdownRef, }; }; diff --git a/client/src/components/Design/token/zIndex.ts b/client/src/components/Design/token/zIndex.ts index 9ed1ef818..fc32d97bc 100644 --- a/client/src/components/Design/token/zIndex.ts +++ b/client/src/components/Design/token/zIndex.ts @@ -13,6 +13,7 @@ const BOTTOM_SHEET_DIMMED_LAYER = NUMBER_KEYBOARD_BOTTOM_SHEET + ABOVE; const BOTTOM_SHEET_CONTAINER = BOTTOM_SHEET_DIMMED_LAYER + ABOVE; const TOAST = BOTTOM_SHEET_CONTAINER + ABOVE; const SELECT_OPTION = ABOVE; +const DROPDOWN_LIST = BASE + ABOVE; export const ZINDEX = { bottomSheetDimmedLayer: BOTTOM_SHEET_DIMMED_LAYER, @@ -25,6 +26,7 @@ export const ZINDEX = { tabText: TAB_TEXT, toast: TOAST, selectOption: SELECT_OPTION, + dropdownList: DROPDOWN_LIST, } as const; type ZIndexKeys = @@ -37,6 +39,7 @@ type ZIndexKeys = | 'tabText' | 'tabIndicator' | 'toast' - | 'selectOption'; + | 'selectOption' + | 'dropdownList'; export type ZIndexTokens = Record<ZIndexKeys, number>; diff --git a/client/src/components/ShareEventButton/DesktopShareEventButton.tsx b/client/src/components/ShareEventButton/DesktopShareEventButton.tsx index 4063b77ad..cf7408ad3 100644 --- a/client/src/components/ShareEventButton/DesktopShareEventButton.tsx +++ b/client/src/components/ShareEventButton/DesktopShareEventButton.tsx @@ -1,29 +1,24 @@ -import CopyToClipboard from 'react-copy-to-clipboard'; - import toast from '@hooks/useToast/toast'; import {Button} from '@components/Design'; -type DesktopShareEventButtonProps = React.HTMLAttributes<HTMLButtonElement> & { - shareText: string; - text: string; +type DesktopShareEventButtonProps = React.PropsWithChildren<React.HTMLAttributes<HTMLButtonElement>> & { + onCopy: () => Promise<void>; }; -const DesktopShareEventButton = ({shareText, text, onClick}: DesktopShareEventButtonProps) => { +const DesktopShareEventButton = ({onCopy, children, ...buttonProps}: DesktopShareEventButtonProps) => { + const copyAndToast = async () => { + await onCopy(); + toast.confirm('링크가 복사되었어요 :) \n참여자들에게 링크를 공유해 주세요!', { + showingTime: 3000, + position: 'bottom', + }); + }; + return ( - <CopyToClipboard - text={shareText} - onCopy={() => - toast.confirm('링크가 복사되었어요 :) \n참여자들에게 링크를 공유해 주세요!', { - showingTime: 3000, - position: 'bottom', - }) - } - > - <Button size="small" variants="tertiary" onClick={onClick} style={{marginRight: '1rem'}}> - {text} - </Button> - </CopyToClipboard> + <Button size="small" variants="tertiary" onClick={copyAndToast} style={{marginRight: '1rem'}} {...buttonProps}> + {children} + </Button> ); }; diff --git a/client/src/components/ShareEventButton/MobileShareEventButton.tsx b/client/src/components/ShareEventButton/MobileShareEventButton.tsx index 8526380e4..368dd611a 100644 --- a/client/src/components/ShareEventButton/MobileShareEventButton.tsx +++ b/client/src/components/ShareEventButton/MobileShareEventButton.tsx @@ -1,14 +1,28 @@ -import {Button} from '@components/Design'; +import toast from '@hooks/useToast/toast'; -type MobileShareEventButtonProps = React.HTMLAttributes<HTMLButtonElement> & { - text: string; +import {Dropdown, DropdownButton} from '@components/Design'; + +type MobileShareEventButtonProps = { + copyShare: () => Promise<void>; + kakaoShare: () => void; }; -const MobileShareEventButton = ({text, ...buttonProps}: MobileShareEventButtonProps) => { +const MobileShareEventButton = ({copyShare, kakaoShare}: MobileShareEventButtonProps) => { + const copyAndToast = async () => { + await copyShare(); + toast.confirm('링크가 복사되었어요 :) \n참여자들에게 링크를 공유해 주세요!', { + showingTime: 3000, + position: 'bottom', + }); + }; + return ( - <Button size="small" variants="tertiary" style={{marginRight: '1rem'}} {...buttonProps}> - {text} - </Button> + <div style={{marginRight: '1rem'}}> + <Dropdown base="button" baseButtonText="정산 초대하기"> + <DropdownButton text="링크 복사하기" onClick={copyAndToast} /> + <DropdownButton text="카카오톡으로 초대하기" onClick={kakaoShare} /> + </Dropdown> + </div> ); }; diff --git a/client/src/hooks/useShareEvent.ts b/client/src/hooks/useShareEvent.ts index 7e2b6a39f..406357dc2 100644 --- a/client/src/hooks/useShareEvent.ts +++ b/client/src/hooks/useShareEvent.ts @@ -1,33 +1,26 @@ -import {useNavigate} from 'react-router-dom'; - -import {Event} from 'types/serviceType'; - -import {useAuthStore} from '@store/authStore'; - import getEventIdByUrl from '@utils/getEventIdByUrl'; import getEventPageUrlByEnvironment from '@utils/getEventPageUrlByEnvironment'; -import getDeletedLastPath from '@utils/getDeletedLastPath'; - -import toast from './useToast/toast'; type UserShareEventProps = { - event: Event; - isMobile: boolean; + eventName: string; }; -const useShareEvent = ({event, isMobile}: UserShareEventProps) => { - const {eventName, bankName, accountNumber} = event; +const useShareEvent = ({eventName}: UserShareEventProps) => { const eventId = getEventIdByUrl(); const url = getEventPageUrlByEnvironment(eventId, 'home'); - const navigate = useNavigate(); - const {isAdmin} = useAuthStore(); const shareInfo = { - title: `[행동대장]\n${eventName}에 대한 정산을 시작할게요:)`, + title: `행동대장이 ${eventName}에\n대한 정산을 요청했어요 :)`, text: '아래 링크에 접속해서 정산 내역을 확인해 주세요!', url, }; + const shareText = `${shareInfo.title}\n${shareInfo.text}\n${url}`; + + const copyShare = async () => { + await window.navigator.clipboard.writeText(shareText); + }; + const kakaoShare = () => { window.Kakao.Share.sendDefault({ objectType: 'feed', @@ -45,40 +38,9 @@ const useShareEvent = ({event, isMobile}: UserShareEventProps) => { }); }; - const shareText = `${shareInfo.title}\n${shareInfo.text}\n${url}`; - - const onShareButtonClick = () => { - const isReady = bankName !== '' && accountNumber !== ''; - - // induceBankInfoBeforeShare - if (!isReady && isAdmin) { - toast.error('잠깐! 정산을 초대하기 전에\n계좌를 등록해주세요', { - showingTime: 3000, - position: 'bottom', - }); - - const navigatePath = `${getDeletedLastPath(location.pathname)}/admin/edit`; - navigate(navigatePath); - return; - } - - if (!isReady && !isAdmin) { - toast.error('정산자가 계좌를 등록해야 초대 가능합니다.\n정산자에게 문의해주세요', { - showingTime: 3000, - position: 'bottom', - }); - return; - } - - // 모바일이 아닌 기기는 단순 텍스트 복사 - // 모바일 기기에서는 카카오톡 공유를 사용 - if (!isMobile) return; - kakaoShare(); - }; - return { - shareText, - onShareButtonClick: kakaoShare, + kakaoShare, + copyShare, }; }; diff --git a/client/src/pages/EventPage/EventPageLayout.tsx b/client/src/pages/EventPage/EventPageLayout.tsx index dfc7e42ec..59bac904f 100644 --- a/client/src/pages/EventPage/EventPageLayout.tsx +++ b/client/src/pages/EventPage/EventPageLayout.tsx @@ -24,8 +24,7 @@ const EventPageLayout = () => { }; const isMobile = isMobileDevice(); - - const {shareText, onShareButtonClick} = useShareEvent({event, isMobile}); + const {kakaoShare, copyShare} = useShareEvent({eventName: event.eventName}); return ( <MainLayout backgroundColor="gray"> @@ -40,9 +39,9 @@ const EventPageLayout = () => { <TopNav.Item displayName="관리" routePath="/admin" /> </TopNav> {isMobile ? ( - <MobileShareEventButton text="카카오톡으로 초대하기" onClick={onShareButtonClick} /> + <MobileShareEventButton copyShare={copyShare} kakaoShare={kakaoShare} /> ) : ( - <DesktopShareEventButton text="정산 초대하기" shareText={shareText} onClick={onShareButtonClick} /> + <DesktopShareEventButton onCopy={copyShare}>정산 초대하기</DesktopShareEventButton> )} </Flex> <Outlet context={outletContext} /> From 897af5b6693ce7feb85444a329fc4a12cce32187 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Thu, 10 Oct 2024 18:09:22 +0900 Subject: [PATCH 16/20] =?UTF-8?q?feat:=20amplitude=20tracking=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#733)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: amplitude 트래킹 코드 구현 Co-authored-by: JinHo Kim <jinhokim98@users.noreply.github.com> * chore: 불필요한 console.log제거 * feat: 이벤트 생성, 시작버튼 클릭, 지출내역 체류시간 track 함수 제작 * feat: track 코드에 진입 브라우저 프로퍼티 추가 * feat: 정산 시작하기 track 코드 추가 * feat: 이벤트 로더에서 총액 계산 * feat: 이벤트 초대 클릭 track 코드 추가 * feat: 지출내역 시작, 종료 tracking 코드 추가 * feat: 송금하기 tracking 코드 추가 * chore: 필요하지 않은 의존성 제거 * style: lint 맞춰줌 * fix: 개발 편의로 인한 sentry 주석 해제 --------- Co-authored-by: 이태훈 <rhymint@gmail.com> Co-authored-by: JinHo Kim <jinhokim98@users.noreply.github.com> --- client/index.html | 10 -- client/package-lock.json | 119 +++++++++++++++++- client/package.json | 1 + client/src/App.tsx | 9 +- .../AmplitudeInitializer.tsx | 17 +++ client/src/components/Loader/EventLoader.tsx | 18 +++ .../hooks/queries/step/useRequestGetSteps.ts | 10 -- client/src/hooks/useAmplitude.ts | 83 ++++++++++++ client/src/hooks/useEventPageLayout.ts | 17 +++ client/src/hooks/useMembersStep.ts | 9 +- client/src/hooks/useReportsPage.ts | 6 +- client/src/hooks/useSendPage.ts | 11 +- client/src/hooks/useSetEventPasswordStep.ts | 5 +- .../CreateEventPage/SetEventPasswordStep.tsx | 2 +- .../pages/EventPage/AdminPage/AdminPage.tsx | 3 + .../src/pages/EventPage/EventPageLayout.tsx | 20 ++- client/src/pages/MainPage/MainPage.tsx | 8 +- client/src/pages/MainPage/Nav/Nav.tsx | 24 ++-- .../pages/MainPage/Section/MainSection.tsx | 13 +- client/src/store/amplitudeStore.ts | 14 +++ client/src/types/amplitude.ts | 56 +++++++++ client/src/utils/detectBrowser.ts | 32 +++++ 22 files changed, 438 insertions(+), 49 deletions(-) create mode 100644 client/src/components/AmplitudeInitializer/AmplitudeInitializer.tsx create mode 100644 client/src/hooks/useAmplitude.ts create mode 100644 client/src/store/amplitudeStore.ts create mode 100644 client/src/types/amplitude.ts create mode 100644 client/src/utils/detectBrowser.ts diff --git a/client/index.html b/client/index.html index a7a7106c3..6ca0b92cb 100644 --- a/client/index.html +++ b/client/index.html @@ -35,16 +35,6 @@ <link rel="canonical" href="https://haengdong.pro/" /> <link rel="icon" href="favicon.ico" type="image/x-icon" /> - - <script src="https://cdn.amplitude.com/libs/analytics-browser-2.9.3-min.js.gz"></script> - <script src="https://cdn.amplitude.com/libs/plugin-session-replay-browser-1.6.8-min.js.gz"></script> - <script src="https://cdn.amplitude.com/libs/plugin-autocapture-browser-1.0.0-min.js.gz"></script> - <script> - window.amplitude.add(window.sessionReplay.plugin({sampleRate: 1})).promise.then(function () { - window.amplitude.add(window.amplitudeAutocapturePlugin.plugin()); - window.amplitude.init('<%= process.env.AMPLITUDE_KEY %>'); - }); - </script> <script src="https://t1.kakaocdn.net/kakao_js_sdk/2.7.2/kakao.min.js" integrity="sha384-TiCUE00h649CAMonG018J2ujOgDKW/kVWlChEuu4jK2vxfAAD0eZxzCKakxg55G4" diff --git a/client/package-lock.json b/client/package-lock.json index 018acf6d8..9f54768f4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@amplitude/analytics-browser": "^2.11.7", "@emotion/react": "^11.11.4", "@sentry/react": "^8.25.0", "@tanstack/react-query": "^5.51.23", @@ -95,6 +96,120 @@ "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", "dev": true }, + "node_modules/@amplitude/analytics-browser": { + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-browser/-/analytics-browser-2.11.7.tgz", + "integrity": "sha512-tbBeMA2n1JQJlHGEa1LQ7XrSGPM4YKrNdFKV+gDCzqOixx4wBwwYCxN33lVwCpJvCPAjtYonkAO2dq9d78U7Pw==", + "dependencies": { + "@amplitude/analytics-client-common": "^2.3.3", + "@amplitude/analytics-core": "^2.5.2", + "@amplitude/analytics-remote-config": "^0.4.0", + "@amplitude/analytics-types": "^2.8.2", + "@amplitude/plugin-autocapture-browser": "^1.0.2", + "@amplitude/plugin-page-view-tracking-browser": "^2.3.3", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-browser/node_modules/@amplitude/analytics-core": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.5.2.tgz", + "integrity": "sha512-4ojYUL7LA+qrlaz1n1nxpsbpgS1k6DOrQ3fBiQOuDJE8Av0aZfylDksFPnZvD1+MMdIm/ONkVAYfEaW3x/uH3Q==", + "dependencies": { + "@amplitude/analytics-types": "^2.8.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-browser/node_modules/@amplitude/analytics-types": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.8.2.tgz", + "integrity": "sha512-SWFXIMxhFm1/k3PUvxvYLY1iwzS28yd9A6pa5pEnrbaAZwM+E/24ucxs59VGp1N5qlIsvF0aVGSoKzN4ydh4eA==" + }, + "node_modules/@amplitude/analytics-client-common": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-client-common/-/analytics-client-common-2.3.3.tgz", + "integrity": "sha512-HOvD2A8DH0y2LATrQ0AhFSOCk6yZayebu4+UsEBT76Q7EEYtnc59WMCRRIi5Zv6OqHpOvABumF7g3HmL6ehTYA==", + "dependencies": { + "@amplitude/analytics-connector": "^1.4.8", + "@amplitude/analytics-core": "^2.5.2", + "@amplitude/analytics-types": "^2.8.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-client-common/node_modules/@amplitude/analytics-core": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-2.5.2.tgz", + "integrity": "sha512-4ojYUL7LA+qrlaz1n1nxpsbpgS1k6DOrQ3fBiQOuDJE8Av0aZfylDksFPnZvD1+MMdIm/ONkVAYfEaW3x/uH3Q==", + "dependencies": { + "@amplitude/analytics-types": "^2.8.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-client-common/node_modules/@amplitude/analytics-types": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.8.2.tgz", + "integrity": "sha512-SWFXIMxhFm1/k3PUvxvYLY1iwzS28yd9A6pa5pEnrbaAZwM+E/24ucxs59VGp1N5qlIsvF0aVGSoKzN4ydh4eA==" + }, + "node_modules/@amplitude/analytics-connector": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-connector/-/analytics-connector-1.5.0.tgz", + "integrity": "sha512-T8mOYzB9RRxckzhL0NTHwdge9xuFxXEOplC8B1Y3UX3NHa3BLh7DlBUZlCOwQgMc2nxDfnSweDL5S3bhC+W90g==" + }, + "node_modules/@amplitude/analytics-core": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-core/-/analytics-core-1.2.5.tgz", + "integrity": "sha512-V7CVlHVN+1diKiOpdp2bCPZ0mbS4CmUYF+v+eXDwVfJL3M/t3sVcT1apXnmVYGYi14cGu9hQOD11rD6qKbUOsw==", + "dependencies": { + "@amplitude/analytics-types": "^1.3.4", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-remote-config": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-remote-config/-/analytics-remote-config-0.4.1.tgz", + "integrity": "sha512-BYl6kQ9qjztrCACsugpxO+foLaQIC0aSEzoXEAb/gwOzInmqkyyI+Ub+aWTBih4xgB/lhWlOcidWHAmNiTJTNw==", + "dependencies": { + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-core": ">=1 <3", + "@amplitude/analytics-types": ">=1 <3", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/analytics-types": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-1.3.4.tgz", + "integrity": "sha512-tR70gzqFkEzX9QpxvWYMfLCledT7vMhgd3d4/bkp3nnGXTOORaVUOCcSgOyxyuFdSx84T61aP/eZPKIcZcaP+A==" + }, + "node_modules/@amplitude/plugin-autocapture-browser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-autocapture-browser/-/plugin-autocapture-browser-1.0.3.tgz", + "integrity": "sha512-XUQWUAw9VqtJPlmOyWjnhsEspyVakd9LuSjVNtLjhwlWv+f/yZM1AAQVUdq/Os1+b5OptSgJQ2pPfRJJHZDXTw==", + "dependencies": { + "@amplitude/analytics-client-common": ">=1 <3", + "@amplitude/analytics-types": "^2.8.2", + "rxjs": "^7.8.1", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-autocapture-browser/node_modules/@amplitude/analytics-types": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.8.2.tgz", + "integrity": "sha512-SWFXIMxhFm1/k3PUvxvYLY1iwzS28yd9A6pa5pEnrbaAZwM+E/24ucxs59VGp1N5qlIsvF0aVGSoKzN4ydh4eA==" + }, + "node_modules/@amplitude/plugin-page-view-tracking-browser": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@amplitude/plugin-page-view-tracking-browser/-/plugin-page-view-tracking-browser-2.3.3.tgz", + "integrity": "sha512-tGDt7aU/okGHH38sxZvBckRXBw2btkw+wJz/PmIFIQdD7/7EMVnTtXgZJTokE+fqfxCx79F2ojEHp37xgVSxpg==", + "dependencies": { + "@amplitude/analytics-client-common": "^2.3.3", + "@amplitude/analytics-types": "^2.8.2", + "tslib": "^2.4.1" + } + }, + "node_modules/@amplitude/plugin-page-view-tracking-browser/node_modules/@amplitude/analytics-types": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@amplitude/analytics-types/-/analytics-types-2.8.2.tgz", + "integrity": "sha512-SWFXIMxhFm1/k3PUvxvYLY1iwzS28yd9A6pa5pEnrbaAZwM+E/24ucxs59VGp1N5qlIsvF0aVGSoKzN4ydh4eA==" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -20197,7 +20312,6 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, "dependencies": { "tslib": "^2.1.0" } @@ -22187,8 +22301,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tunnel-agent": { "version": "0.6.0", diff --git a/client/package.json b/client/package.json index 932e4e911..b10b7b9d0 100644 --- a/client/package.json +++ b/client/package.json @@ -85,6 +85,7 @@ "webpack-merge": "^6.0.1" }, "dependencies": { + "@amplitude/analytics-browser": "^2.11.7", "@emotion/react": "^11.11.4", "@sentry/react": "^8.25.0", "@tanstack/react-query": "^5.51.23", diff --git a/client/src/App.tsx b/client/src/App.tsx index 33ea71b6c..99e09da87 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -6,6 +6,7 @@ import QueryClientBoundary from '@components/QueryClientBoundary/QueryClientBoun import ErrorCatcher from '@components/AppErrorBoundary/ErrorCatcher'; import ToastContainer from '@components/Toast/ToastContainer'; import KakaoInitializer from '@components/KakaoInitializer/KakaoInitializer'; +import AmplitudeInitializer from '@components/AmplitudeInitializer/AmplitudeInitializer'; import {HDesignProvider} from '@HDesign/index'; @@ -24,9 +25,11 @@ const App: React.FC = () => { <ReactQueryDevtools initialIsOpen={false} /> <NetworkStateCatcher /> <ToastContainer /> - <KakaoInitializer> - <Outlet /> - </KakaoInitializer> + <AmplitudeInitializer> + <KakaoInitializer> + <Outlet /> + </KakaoInitializer> + </AmplitudeInitializer> </QueryClientBoundary> </ErrorCatcher> </UnPredictableErrorBoundary> diff --git a/client/src/components/AmplitudeInitializer/AmplitudeInitializer.tsx b/client/src/components/AmplitudeInitializer/AmplitudeInitializer.tsx new file mode 100644 index 000000000..bd3ac2627 --- /dev/null +++ b/client/src/components/AmplitudeInitializer/AmplitudeInitializer.tsx @@ -0,0 +1,17 @@ +import {useEffect} from 'react'; + +import {useAmplitudeStore} from '@store/amplitudeStore'; + +const AmplitudeInitializer = ({children}: React.PropsWithChildren) => { + const {amplitude} = useAmplitudeStore(); + + useEffect(() => { + amplitude.init(process.env.AMPLITUDE_KEY, undefined, { + defaultTracking: true, + }); + }, []); + + return children; +}; + +export default AmplitudeInitializer; diff --git a/client/src/components/Loader/EventLoader.tsx b/client/src/components/Loader/EventLoader.tsx index c79337319..f4ef9fd07 100644 --- a/client/src/components/Loader/EventLoader.tsx +++ b/client/src/components/Loader/EventLoader.tsx @@ -1,9 +1,13 @@ import {useQueries} from '@tanstack/react-query'; +import {useEffect} from 'react'; import {requestGetEvent} from '@apis/request/event'; import {requestGetReports} from '@apis/request/report'; import {requestGetSteps} from '@apis/request/step'; import {WithErrorHandlingStrategy} from '@errors/RequestGetError'; +import {requestGetAllMembers} from '@apis/request/member'; + +import {useTotalExpenseAmountStore} from '@store/totalExpenseAmountStore'; import getEventIdByUrl from '@utils/getEventIdByUrl'; @@ -23,9 +27,23 @@ const EventLoader = ({children, ...props}: React.PropsWithChildren<WithErrorHand queryKey: [QUERY_KEYS.steps], queryFn: () => requestGetSteps({eventId, ...props}), }, + { + queryKey: [QUERY_KEYS.allMembers], + queryFn: () => requestGetAllMembers({eventId, ...props}), + }, ], }); + const {updateTotalExpenseAmount} = useTotalExpenseAmountStore(); + + const stepsData = queries[2]; + + useEffect(() => { + if (stepsData.isSuccess && stepsData.data) { + updateTotalExpenseAmount(stepsData.data); + } + }, [stepsData.data, stepsData.isSuccess, updateTotalExpenseAmount]); + const isLoading = queries.some(query => query.isLoading === true); return !isLoading && children; diff --git a/client/src/hooks/queries/step/useRequestGetSteps.ts b/client/src/hooks/queries/step/useRequestGetSteps.ts index 7b70bacef..54e1ae831 100644 --- a/client/src/hooks/queries/step/useRequestGetSteps.ts +++ b/client/src/hooks/queries/step/useRequestGetSteps.ts @@ -1,30 +1,20 @@ import {useQuery} from '@tanstack/react-query'; -import {useEffect} from 'react'; import {requestGetSteps} from '@apis/request/step'; import {WithErrorHandlingStrategy} from '@errors/RequestGetError'; -import {useTotalExpenseAmountStore} from '@store/totalExpenseAmountStore'; - import getEventIdByUrl from '@utils/getEventIdByUrl'; import QUERY_KEYS from '@constants/queryKeys'; const useRequestGetSteps = ({...props}: WithErrorHandlingStrategy | null = {}) => { const eventId = getEventIdByUrl(); - const {updateTotalExpenseAmount} = useTotalExpenseAmountStore(); const queryResult = useQuery({ queryKey: [QUERY_KEYS.steps], queryFn: () => requestGetSteps({eventId, ...props}), }); - useEffect(() => { - if (queryResult.isSuccess && queryResult.data) { - updateTotalExpenseAmount(queryResult.data); - } - }, [queryResult.data, queryResult.isSuccess, updateTotalExpenseAmount]); - return { steps: queryResult.data ?? [], ...queryResult, diff --git a/client/src/hooks/useAmplitude.ts b/client/src/hooks/useAmplitude.ts new file mode 100644 index 000000000..fd13c75ca --- /dev/null +++ b/client/src/hooks/useAmplitude.ts @@ -0,0 +1,83 @@ +import {useAmplitudeStore} from '@store/amplitudeStore'; + +import detectBrowser from '@utils/detectBrowser'; + +type EventUniqueData = { + eventName: string; + eventToken: string; +}; + +type ShareMethod = 'link' | 'kakao'; + +export type EventSummary = EventUniqueData & { + totalExpenseAmount: number; // 총 지출금액 + allMembersCount: number; // 행사에 참여한 총 인원 + billsCount: number; // 총 지출내역 수 + isAdmin: boolean; // 관리자 여부 + shareMethod: ShareMethod; // 공유 방법 +}; + +type SendMethod = 'clipboard' | 'toss' | 'kakaopay'; + +type SendMoneyData = EventUniqueData & { + sendMethod: SendMethod; + amount: number; +}; + +const useAmplitude = () => { + const {amplitude} = useAmplitudeStore(); + const domainEnv = process.env.NODE_ENV; + + const track = (eventName: string, eventProps: Record<string, any> = {}) => { + amplitude.track(eventName, { + domain: domainEnv, + browser: detectBrowser(), + ...eventProps, + }); + }; + + const trackStartCreateEvent = () => { + track('정산 시작하기 버튼 클릭'); + }; + + const trackCompleteCreateEvent = (eventUniqueData: EventUniqueData) => { + track('이벤트 생성 완료', { + ...eventUniqueData, + }); + }; + + const trackShareEvent = (eventSummary: EventSummary) => { + track('이벤트 초대 클릭', { + ...eventSummary, + }); + }; + + const trackAddBillStart = (eventUniqueData: EventUniqueData) => { + track('지출내역 추가 시작', { + ...eventUniqueData, + }); + }; + + const trackAddBillEnd = (eventUniqueData: EventUniqueData) => { + track('지출내역 추가 완료', { + ...eventUniqueData, + }); + }; + + const trackSendMoney = (sendMoneyData: SendMoneyData) => { + track('송금 버튼 클릭', { + ...sendMoneyData, + }); + }; + + return { + trackStartCreateEvent, + trackCompleteCreateEvent, + trackShareEvent, + trackAddBillStart, + trackAddBillEnd, + trackSendMoney, + }; +}; + +export default useAmplitude; diff --git a/client/src/hooks/useEventPageLayout.ts b/client/src/hooks/useEventPageLayout.ts index 6e645d186..a443981e4 100644 --- a/client/src/hooks/useEventPageLayout.ts +++ b/client/src/hooks/useEventPageLayout.ts @@ -1,13 +1,20 @@ import {useAuthStore} from '@store/authStore'; +import {useTotalExpenseAmountStore} from '@store/totalExpenseAmountStore'; import getEventIdByUrl from '@utils/getEventIdByUrl'; import useRequestGetEvent from './queries/event/useRequestGetEvent'; +import useRequestGetAllMembers from './queries/member/useRequestGetAllMembers'; +import useRequestGetSteps from './queries/step/useRequestGetSteps'; const useEventPageLayout = () => { const eventId = getEventIdByUrl(); const {eventName, bankName, accountNumber} = useRequestGetEvent(); const {isAdmin} = useAuthStore(); + const {totalExpenseAmount} = useTotalExpenseAmountStore(); + const {members} = useRequestGetAllMembers(); + const {steps} = useRequestGetSteps(); + const billsCount = steps.flatMap(step => [...step.bills]).length; const event = { eventName, @@ -15,10 +22,20 @@ const useEventPageLayout = () => { accountNumber, }; + const eventSummary = { + eventName, + eventToken: eventId, + totalExpenseAmount, + allMembersCount: members.length, + billsCount, + isAdmin, + }; + return { eventId, isAdmin, event, + eventSummary, }; }; diff --git a/client/src/hooks/useMembersStep.ts b/client/src/hooks/useMembersStep.ts index e494884b5..1348cb38d 100644 --- a/client/src/hooks/useMembersStep.ts +++ b/client/src/hooks/useMembersStep.ts @@ -1,4 +1,4 @@ -import {RefObject, useEffect, useRef, useState} from 'react'; +import {useEffect, useRef, useState} from 'react'; import {useNavigate} from 'react-router-dom'; import {BillInfo} from '@pages/AddBillFunnel/AddBillFunnel'; @@ -13,6 +13,8 @@ import useRequestPostMembers from './queries/member/useRequestPostMembers'; import useRequestPostBill from './queries/bill/useRequestPostBill'; import {BillStep} from './useAddBillFunnel'; import useRequestGetAllMembers from './queries/member/useRequestGetAllMembers'; +import useAmplitude from './useAmplitude'; +import useRequestGetEvent from './queries/event/useRequestGetEvent'; interface Props { billInfo: BillInfo; @@ -27,12 +29,15 @@ const useMembersStep = ({billInfo, setBillInfo, currentMembers, setStep}: Props) const inputRef = useRef<HTMLInputElement>(null); const hiddenRef = useRef<HTMLInputElement>(null); + const {trackAddBillEnd} = useAmplitude(); + const {members: allMembers} = useRequestGetAllMembers(); const {postMembersAsync, isPending: isPendingPostMembers} = useRequestPostMembers(); const {postBill, isSuccess: isSuccessPostBill, isPending: isPendingPostBill} = useRequestPostBill(); const navigate = useNavigate(); const eventId = getEventIdByUrl(); + const {eventName} = useRequestGetEvent(); const onNameInputChange = (value: string) => { if (REGEXP.memberName.test(value)) { @@ -111,6 +116,8 @@ const useMembersStep = ({billInfo, setBillInfo, currentMembers, setStep}: Props) memberIds: billInfo.members.map(({id}) => id), }); } + + trackAddBillEnd({eventName, eventToken: eventId}); }; useEffect(() => { diff --git a/client/src/hooks/useReportsPage.ts b/client/src/hooks/useReportsPage.ts index ea7eea31a..339171945 100644 --- a/client/src/hooks/useReportsPage.ts +++ b/client/src/hooks/useReportsPage.ts @@ -12,11 +12,13 @@ export type SendInfo = { bankName: string; accountNumber: string; amount: number; + eventName: string; + eventToken: string; }; const useReportsPage = () => { const [memberName, setMemberName] = useState(''); - const {bankName, accountNumber} = useOutletContext<EventPageContextProps>(); + const {eventName, eventToken, bankName, accountNumber} = useOutletContext<EventPageContextProps>(); const {matchedReports, reports} = useSearchReports({memberName}); const location = useLocation(); @@ -31,6 +33,8 @@ const useReportsPage = () => { bankName, accountNumber, amount, + eventName, + eventToken, }; navigate(`${getDeletedLastPath(location.pathname)}/${memberId}/send`, {state: sendInfo}); diff --git a/client/src/hooks/useSendPage.ts b/client/src/hooks/useSendPage.ts index 6b3cc9bf5..d90c94403 100644 --- a/client/src/hooks/useSendPage.ts +++ b/client/src/hooks/useSendPage.ts @@ -3,6 +3,7 @@ import {useEffect, useState} from 'react'; import {SendInfo} from './useReportsPage'; import toast from './useToast/toast'; +import useAmplitude from './useAmplitude'; export type SendMethod = '복사하기' | '토스' | '카카오페이'; export type OnSend = () => void | Promise<void>; @@ -11,6 +12,8 @@ const useSendPage = () => { const [sendMethod, setSendMethod] = useState<SendMethod>('토스'); const state = useLocation().state as SendInfo; + const {trackSendMoney} = useAmplitude(); + const options: SendMethod[] = ['토스', '카카오페이', '복사하기']; const defaultValue: SendMethod = '토스'; @@ -24,7 +27,7 @@ const useSendPage = () => { } }, [state]); - const {bankName, accountNumber, amount} = state; + const {bankName, accountNumber, amount, eventName, eventToken} = state; const format = (accountNumber: string) => { if (accountNumber.length > 9) { @@ -40,16 +43,22 @@ const useSendPage = () => { const onCopy = async () => { await window.navigator.clipboard.writeText(copyText); + + trackSendMoney({eventName, eventToken, amount, sendMethod: 'clipboard'}); toast.confirm('금액이 복사되었어요.'); }; const onTossClick = () => { + trackSendMoney({eventName, eventToken, amount, sendMethod: 'toss'}); + const tossUrl = `supertoss://send?amount=${amount}&bank=${bankName}&accountNo=${accountNumber}`; window.location.href = tossUrl; }; const onKakaoPayClick = async () => { await window.navigator.clipboard.writeText(copyText); + trackSendMoney({eventName, eventToken, amount, sendMethod: 'kakaopay'}); + const kakaoPayUrl = 'kakaotalk://kakaopay/home'; window.location.href = kakaoPayUrl; }; diff --git a/client/src/hooks/useSetEventPasswordStep.ts b/client/src/hooks/useSetEventPasswordStep.ts index 5705be855..78f748a65 100644 --- a/client/src/hooks/useSetEventPasswordStep.ts +++ b/client/src/hooks/useSetEventPasswordStep.ts @@ -1,11 +1,11 @@ import {useState} from 'react'; -import {useNavigate} from 'react-router-dom'; import validateEventPassword from '@utils/validate/validateEventPassword'; import RULE from '@constants/rule'; import useRequestPostEvent from './queries/event/useRequestPostEvent'; +import useAmplitude from './useAmplitude'; export type UseSetEventPasswordStepReturnType = ReturnType<typeof useSetEventPasswordStep>; @@ -15,6 +15,8 @@ const useSetEventPasswordStep = () => { const [canSubmit, setCanSubmit] = useState(false); const {postEvent: requestPostEvent, isPostEventPending} = useRequestPostEvent(); + const {trackCompleteCreateEvent} = useAmplitude(); + const submitDataForPostEvent = async ({ event, eventName, @@ -38,6 +40,7 @@ const useSetEventPasswordStep = () => { {eventName, password: getPasswordWithPad()}, { onSuccess: ({eventId}) => { + trackCompleteCreateEvent({eventName, eventToken: eventId}); updateEventToken(eventId); }, }, diff --git a/client/src/pages/CreateEventPage/SetEventPasswordStep.tsx b/client/src/pages/CreateEventPage/SetEventPasswordStep.tsx index 6c29fcac3..e31cb9985 100644 --- a/client/src/pages/CreateEventPage/SetEventPasswordStep.tsx +++ b/client/src/pages/CreateEventPage/SetEventPasswordStep.tsx @@ -2,7 +2,7 @@ import {css} from '@emotion/react'; import Top from '@components/Design/components/Top/Top'; -import useSetEventPasswordStep, {UseSetEventPasswordStepReturnType} from '@hooks/useSetEventPasswordStep'; +import useSetEventPasswordStep from '@hooks/useSetEventPasswordStep'; import {FixedButton, Input} from '@HDesign/index'; diff --git a/client/src/pages/EventPage/AdminPage/AdminPage.tsx b/client/src/pages/EventPage/AdminPage/AdminPage.tsx index 0b3b1c79d..a6c32f855 100644 --- a/client/src/pages/EventPage/AdminPage/AdminPage.tsx +++ b/client/src/pages/EventPage/AdminPage/AdminPage.tsx @@ -4,6 +4,7 @@ import StepList from '@components/StepList/Steps'; import {Banner} from '@components/Design/components/Banner'; import useAdminPage from '@hooks/useAdminPage'; +import useAmplitude from '@hooks/useAmplitude'; import {Title, Button, Dropdown, DropdownButton} from '@HDesign/index'; @@ -11,6 +12,7 @@ import {receiptStyle} from './AdminPage.style'; const AdminPage = () => { const navigate = useNavigate(); + const {trackAddBillStart} = useAmplitude(); const {eventId, isAdmin, eventName, totalExpenseAmount, isShowBanner, onDelete, steps} = useAdminPage(); @@ -27,6 +29,7 @@ const AdminPage = () => { }; const navigateAddBill = () => { + trackAddBillStart({eventName, eventToken: eventId}); navigate(`/event/${eventId}/admin/add-bill`); }; diff --git a/client/src/pages/EventPage/EventPageLayout.tsx b/client/src/pages/EventPage/EventPageLayout.tsx index 59bac904f..6a16208d1 100644 --- a/client/src/pages/EventPage/EventPageLayout.tsx +++ b/client/src/pages/EventPage/EventPageLayout.tsx @@ -4,6 +4,7 @@ import {Outlet} from 'react-router-dom'; import useEventPageLayout from '@hooks/useEventPageLayout'; import useShareEvent from '@hooks/useShareEvent'; +import useAmplitude from '@hooks/useAmplitude'; import {Footer} from '@components/Footer'; import {DesktopShareEventButton, MobileShareEventButton} from '@components/ShareEventButton'; @@ -14,18 +15,31 @@ import {isMobileDevice} from '@utils/detectDevice'; export type EventPageContextProps = Event & { isAdmin: boolean; + eventToken: string; }; const EventPageLayout = () => { - const {isAdmin, event} = useEventPageLayout(); + const {isAdmin, event, eventId, eventSummary} = useEventPageLayout(); const outletContext: EventPageContextProps = { isAdmin, + eventToken: eventId, ...event, }; + const {trackShareEvent} = useAmplitude(); const isMobile = isMobileDevice(); const {kakaoShare, copyShare} = useShareEvent({eventName: event.eventName}); + const trackLinkShare = async () => { + trackShareEvent({...eventSummary, shareMethod: 'link'}); + await copyShare(); + }; + + const trackKakaoShare = () => { + trackShareEvent({...eventSummary, shareMethod: 'kakao'}); + kakaoShare(); + }; + return ( <MainLayout backgroundColor="gray"> <Flex justifyContent="spaceBetween" alignItems="center"> @@ -39,9 +53,9 @@ const EventPageLayout = () => { <TopNav.Item displayName="관리" routePath="/admin" /> </TopNav> {isMobile ? ( - <MobileShareEventButton copyShare={copyShare} kakaoShare={kakaoShare} /> + <MobileShareEventButton copyShare={trackLinkShare} kakaoShare={trackKakaoShare} /> ) : ( - <DesktopShareEventButton onCopy={copyShare}>정산 초대하기</DesktopShareEventButton> + <DesktopShareEventButton onCopy={trackLinkShare}>정산 초대하기</DesktopShareEventButton> )} </Flex> <Outlet context={outletContext} /> diff --git a/client/src/pages/MainPage/MainPage.tsx b/client/src/pages/MainPage/MainPage.tsx index 702972eff..4d0294ef9 100644 --- a/client/src/pages/MainPage/MainPage.tsx +++ b/client/src/pages/MainPage/MainPage.tsx @@ -1,3 +1,5 @@ +import useAmplitude from '@hooks/useAmplitude'; + import {MainLayout} from '@HDesign/index'; import Nav from './Nav/Nav'; @@ -8,10 +10,12 @@ import AddMemberSection from './Section/AddMemberSection'; import ReportSection from './Section/ReportSection'; const MainPage = () => { + const {trackStartCreateEvent} = useAmplitude(); + return ( <MainLayout> - <Nav /> - <MainSection /> + <Nav trackStartCreateEvent={trackStartCreateEvent} /> + <MainSection trackStartCreateEvent={trackStartCreateEvent} /> <DescriptionSection /> <AddBillSection /> <AddMemberSection /> diff --git a/client/src/pages/MainPage/Nav/Nav.tsx b/client/src/pages/MainPage/Nav/Nav.tsx index 65c7399db..eaf8e13ed 100644 --- a/client/src/pages/MainPage/Nav/Nav.tsx +++ b/client/src/pages/MainPage/Nav/Nav.tsx @@ -1,14 +1,21 @@ import {useNavigate} from 'react-router-dom'; -import {useTheme} from '@components/Design/theme/HDesignProvider'; - -import {Button, Flex, Text, Icon, TopNav, IconButton} from '@HDesign/index'; +import {Button, Text, Icon, TopNav, IconButton} from '@HDesign/index'; import {ROUTER_URLS} from '@constants/routerUrls'; -const Nav = () => { - const {theme} = useTheme(); +type NavProps = { + trackStartCreateEvent: () => void; +}; + +const Nav = ({trackStartCreateEvent}: NavProps) => { const navigate = useNavigate(); + + const StartCreateEvent = () => { + trackStartCreateEvent(); + navigate(ROUTER_URLS.createEvent); + }; + return ( <header style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center', height: '37px'}}> <TopNav> @@ -21,12 +28,7 @@ const Nav = () => { <Text size="subTitle">행동대장</Text> </TopNav.Item> </TopNav> - <Button - size="medium" - variants="tertiary" - onClick={() => navigate(ROUTER_URLS.createEvent)} - style={{marginRight: '1rem'}} - > + <Button size="medium" variants="tertiary" onClick={StartCreateEvent} style={{marginRight: '1rem'}}> 정산 시작하기 </Button> </header> diff --git a/client/src/pages/MainPage/Section/MainSection.tsx b/client/src/pages/MainPage/Section/MainSection.tsx index 1f7b8367f..83f8d525e 100644 --- a/client/src/pages/MainPage/Section/MainSection.tsx +++ b/client/src/pages/MainPage/Section/MainSection.tsx @@ -7,9 +7,18 @@ import Text from '@HDesign/components/Text/Text'; import {ROUTER_URLS} from '@constants/routerUrls'; -const MainSection = () => { +type MainSectionProps = { + trackStartCreateEvent: () => void; +}; + +const MainSection = ({trackStartCreateEvent}: MainSectionProps) => { const navigate = useNavigate(); + const handleClick = () => { + trackStartCreateEvent(); + navigate(ROUTER_URLS.createEvent); + }; + return ( <div css={css({ @@ -39,7 +48,7 @@ const MainSection = () => { <Text css={animateWithDelay(1)} style={{textAlign: 'center'}} size="title">{`행동대장을 통해 간편하게 정산하세요 `}</Text> - <Button css={animateWithDelay(2)} size="large" onClick={() => navigate(ROUTER_URLS.createEvent)}> + <Button id="startCreateEvent" css={animateWithDelay(2)} size="large" onClick={handleClick}> 정산 시작하기 </Button> </div> diff --git a/client/src/store/amplitudeStore.ts b/client/src/store/amplitudeStore.ts new file mode 100644 index 000000000..afbedc560 --- /dev/null +++ b/client/src/store/amplitudeStore.ts @@ -0,0 +1,14 @@ +import * as amplitude from '@amplitude/analytics-browser'; +import {create} from 'zustand'; + +import {Amplitude} from 'types/amplitude'; + +type State = { + amplitude: Amplitude; +}; + +type Action = {}; + +export const useAmplitudeStore = create<State & Action>(() => ({ + amplitude: amplitude as Amplitude, +})); diff --git a/client/src/types/amplitude.ts b/client/src/types/amplitude.ts new file mode 100644 index 000000000..6e222118b --- /dev/null +++ b/client/src/types/amplitude.ts @@ -0,0 +1,56 @@ +import AmplitudeClient from '@amplitude/analytics-types'; + +export interface Amplitude { + add: ( + plugin: AmplitudeClient.Plugin<AmplitudeClient.BrowserClient, AmplitudeClient.BrowserConfig>, + ) => AmplitudeClient.AmplitudeReturn<void>; + extendSession: () => void; + flush: () => AmplitudeClient.AmplitudeReturn<void>; + getDeviceId: () => string | undefined; + getSessionId: () => number | undefined; + getUserId: () => string | undefined; + groupIdentify: ( + groupType: string, + groupName: string | string[], + identify: AmplitudeClient.Identify, + eventOptions?: AmplitudeClient.EventOptions | undefined, + ) => AmplitudeClient.AmplitudeReturn<AmplitudeClient.Result>; + identify: ( + identify: AmplitudeClient.Identify, + eventOptions?: AmplitudeClient.EventOptions | undefined, + ) => AmplitudeClient.AmplitudeReturn<AmplitudeClient.Result>; + init: { + (apiKey: string, options?: AmplitudeClient.BrowserOptions | undefined): AmplitudeClient.AmplitudeReturn<void>; + ( + apiKey: string, + userId?: string | undefined, + options?: AmplitudeClient.BrowserOptions | undefined, + ): AmplitudeClient.AmplitudeReturn<void>; + }; + logEvent: ( + eventInput: string | AmplitudeClient.BaseEvent, + eventProperties?: Record<string, any> | undefined, + eventOptions?: AmplitudeClient.EventOptions | undefined, + ) => AmplitudeClient.AmplitudeReturn<AmplitudeClient.Result>; + remove: (pluginName: string) => AmplitudeClient.AmplitudeReturn<void>; + reset: () => void; + revenue: ( + revenue: AmplitudeClient.Revenue, + eventOptions?: AmplitudeClient.EventOptions | undefined, + ) => AmplitudeClient.AmplitudeReturn<AmplitudeClient.Result>; + setDeviceId: (deviceId: string) => void; + setGroup: ( + groupType: string, + groupName: string | string[], + eventOptions?: AmplitudeClient.EventOptions | undefined, + ) => AmplitudeClient.AmplitudeReturn<AmplitudeClient.Result>; + setOptOut: (optOut: boolean) => void; + setSessionId: (sessionId: number) => void; + setTransport: (transport: AmplitudeClient.TransportType) => void; + setUserId: (userId: string | undefined) => void; + track: ( + eventInput: string | AmplitudeClient.BaseEvent, + eventProperties?: Record<string, any> | undefined, + eventOptions?: AmplitudeClient.EventOptions | undefined, + ) => AmplitudeClient.AmplitudeReturn<AmplitudeClient.Result>; +} diff --git a/client/src/utils/detectBrowser.ts b/client/src/utils/detectBrowser.ts new file mode 100644 index 000000000..7e5557c0a --- /dev/null +++ b/client/src/utils/detectBrowser.ts @@ -0,0 +1,32 @@ +// https://gurtn.tistory.com/214 +const detectBrowser = () => { + const browsers = [ + 'Chrome', + 'Opera', + 'WebTV', + 'Whale', + 'Beonex', + 'Chimera', + 'NetPositive', + 'Phoenix', + 'Firefox', + 'Safari', + 'SkipStone', + 'Netscape', + 'Mozilla', + ]; + + const userAgent = window.navigator.userAgent.toLowerCase(); + + if (userAgent.includes('edg')) { + return 'Edge'; + } + + if (userAgent.includes('trident') || userAgent.includes('msie')) { + return 'Internet Explorer'; + } + + return browsers.find(browser => userAgent.includes(browser.toLowerCase())) || 'Other'; +}; + +export default detectBrowser; From 9921db5b87902adb375e7c9d4cc2e7245d1e2dee Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Thu, 10 Oct 2024 18:12:30 +0900 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20=EC=82=AC=EC=A7=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=20=ED=9B=84=20=EB=8B=A4=EC=8B=9C=20=EC=A0=91?= =?UTF-8?q?=EC=86=8D=20=EC=8B=9C=20=EB=B0=98=EC=98=81=EC=9D=B4=20=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=B2=84=EA=B7=B8=20(#727)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: post를 기다린 후 navigate시켜서 캐시 데이터가 remove 됨을 보장받도록 변경 * feat: 잘못 설정된 canSubmit, setIsPrevImageDeleted 변경 * feat: body 타입이 FormData인 경우 header를 삽입하지 않도록 함 * feat: images POST 요청을 requestPostWithoutResponse 함수를 이용하도록 함 * fix: get 메소드가 오류날 때, 무한 렌더링이 일어나는 문제 수정 --------- Co-authored-by: 이태훈 <rhymint@gmail.com> Co-authored-by: pakxe <pigkill40@naver.com> --- client/src/apis/fetcher.ts | 31 +++++++++++---- client/src/apis/request/images.ts | 21 +--------- .../queries/images/useRequestPostImages.ts | 6 +-- client/src/hooks/useAddImagesPage.ts | 38 ++++++++++--------- 4 files changed, 48 insertions(+), 48 deletions(-) diff --git a/client/src/apis/fetcher.ts b/client/src/apis/fetcher.ts index a1c205739..35671b167 100644 --- a/client/src/apis/fetcher.ts +++ b/client/src/apis/fetcher.ts @@ -21,7 +21,7 @@ type RequestInitWithMethod = Omit<RequestInit, 'method'> & {method: Method}; type HeadersType = [string, string][] | Record<string, string> | Headers; -export type Body = BodyInit | object | null; +export type Body = BodyInit | object | null; //init안에 FormDATA있음. type RequestProps = { baseUrl?: string; @@ -32,6 +32,12 @@ type RequestProps = { method: Method; }; +type CreateRequestInitProps = { + body?: Body; + method: Method; + headers?: HeadersType; +}; + type RequestMethodProps = Omit<RequestProps, 'method'>; type FetchType = { @@ -85,17 +91,26 @@ const prepareRequest = ({baseUrl = API_BASE_URL, method, endpoint, headers, body if (queryParams) url += `?${objectToQueryString(queryParams)}`; + const requestInit = createRequestInit({method, headers, body}); + + return {url, requestInit}; +}; + +const createRequestInit = ({method, headers, body}: CreateRequestInitProps) => { const requestInit: RequestInitWithMethod = { credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...headers, - }, method, - body: body ? JSON.stringify(body) : null, }; - return {url, requestInit}; + if (body instanceof FormData) { + return {...requestInit, body}; + } else { + return { + ...requestInit, + headers: {...headers, 'Content-Type': 'application/json'}, + body: body ? JSON.stringify(body) : null, + }; + } }; const executeRequest = async ({url, requestInit, errorHandlingStrategy}: WithErrorHandlingStrategy<FetchType>) => { @@ -114,7 +129,7 @@ const executeRequest = async ({url, requestInit, errorHandlingStrategy}: WithErr return response; } catch (error) { if (error instanceof Error) { - throw error; // 그대로 RequestError 또는 Error 인스턴스를 던집니다. + throw error; } throw error; diff --git a/client/src/apis/request/images.ts b/client/src/apis/request/images.ts index b881ee3ac..e17061c3c 100644 --- a/client/src/apis/request/images.ts +++ b/client/src/apis/request/images.ts @@ -10,26 +10,7 @@ export interface RequestPostImages { } export const requestPostImages = async ({eventId, formData}: WithEventId<RequestPostImages>) => { - // return await requestPostWithoutResponse({ - // baseUrl: BASE_URL.HD, - // endpoint: `${ADMIN_API_PREFIX}/${eventId}/images`, - // headers: { - // 'Content-Type': 'multipart/form-data', - // }, - // body: formData, - // }); - - // TODO: (@todari): 기존의 request 방식들은 기본적으로 - // header를 Content-Type : application/json 으로 보내주고 있음 - // multipart/form-data 요청을 보내기 위해선 header Content-Type을 빈 객체로 전달해야 함 - fetch(`${BASE_URL.HD}${ADMIN_API_PREFIX}/${eventId}/images`, { - credentials: 'include', - // headers: { - // 'Content-Type': 'multipart/form-data', - // }, - method: 'POST', - body: formData, - }); + await requestPostWithoutResponse({endpoint: `${ADMIN_API_PREFIX}/${eventId}/images`, body: formData}); }; export const requestGetImages = async ({eventId}: WithEventId) => { diff --git a/client/src/hooks/queries/images/useRequestPostImages.ts b/client/src/hooks/queries/images/useRequestPostImages.ts index 619989694..fa49463e7 100644 --- a/client/src/hooks/queries/images/useRequestPostImages.ts +++ b/client/src/hooks/queries/images/useRequestPostImages.ts @@ -10,14 +10,14 @@ const useRequestPostImages = () => { const eventId = getEventIdByUrl(); const queryClient = useQueryClient(); - const {mutate, ...rest} = useMutation({ + const {mutateAsync, ...rest} = useMutation({ mutationFn: ({formData}: RequestPostImages) => requestPostImages({eventId, formData}), onSuccess: () => { - queryClient.invalidateQueries({queryKey: [QUERY_KEYS.images]}); + queryClient.removeQueries({queryKey: [QUERY_KEYS.images]}); }, }); - return {postImages: mutate, ...rest}; + return {postImages: mutateAsync, ...rest}; }; export default useRequestPostImages; diff --git a/client/src/hooks/useAddImagesPage.ts b/client/src/hooks/useAddImagesPage.ts index 0bd45f490..917a109a3 100644 --- a/client/src/hooks/useAddImagesPage.ts +++ b/client/src/hooks/useAddImagesPage.ts @@ -16,7 +16,7 @@ const useAddImagesPage = () => { const [images, setImages] = useState<Array<LoadedImage | AddedImage>>([]); const [isPrevImageDeleted, setIsPrevImageDeleted] = useState(false); const addedImages = images.filter(image => image instanceof File); - const {images: prevImages} = useRequestGetImages(); + const {images: prevImages, isSuccess} = useRequestGetImages(); const urls = images.map(image => { if (image instanceof File) { return URL.createObjectURL(image); @@ -29,15 +29,22 @@ const useAddImagesPage = () => { const navigate = useNavigate(); const eventId = getEventIdByUrl(); - const {postImages, isPending, isSuccess: isSuccessPostImage} = useRequestPostImages(); + const {postImages, isPending} = useRequestPostImages(); const {deleteImage} = useRequestDeleteImage(); useEffect(() => { - if (!prevImages) return; + if (!isSuccess) return; setImages([...prevImages]); - }, [prevImages]); + }, [prevImages, isSuccess]); + + useEffect(() => { + console.log(images); + }, [images]); const handleChangeImages = (event: React.ChangeEvent<HTMLInputElement>) => { + //TODO: (@Todari): 현재 A 이미지 추가 -> A 이미지 x 버튼 눌러 취소 -> 다시 A 이미지 추가 시 업로드 되지 않음 + // event.target.files가 변경되지 않기 때문에 onChange에 넣은 + // handleChangeImages가 실행되지 않아 일어나는 문제로 추정 if (event.target.files) { const dataTransfer = new DataTransfer(); @@ -57,24 +64,26 @@ const useAddImagesPage = () => { deleteImage({ imageId: images[index].id, }); - setIsPrevImageDeleted(false); + setIsPrevImageDeleted(true); } else { setImages(prev => prev.filter((_, idx) => idx !== index)); } }; - const canSubmit = !!addedImages || isPrevImageDeleted; + const canSubmit = addedImages.length !== 0 || isPrevImageDeleted; - const submitImages = () => { - const formData = new FormData(); + const submitImages = async () => { + if (addedImages.length !== 0) { + const formData = new FormData(); - if (!addedImages) return; + for (let i = 0; i < addedImages.length; i++) { + formData.append('images', addedImages[i], addedImages[i].name); + } - for (let i = 0; i < addedImages.length; i++) { - formData.append('images', addedImages[i], addedImages[i].name); + await postImages({formData}); } - postImages({formData}); + navigate(`/event/${eventId}/admin`); }; useEffect(() => { @@ -85,11 +94,6 @@ const useAddImagesPage = () => { }; }, []); - useEffect(() => { - if (!isSuccessPostImage) return; - navigate(`/event/${eventId}/admin`); - }, [isSuccessPostImage]); - return {fileInputRef, handleChangeImages, urls, handleDeleteImage, isPending, canSubmit, submitImages}; }; From fed28402e11b65d2c3a2c35c2f0d1f8e6e2b0735 Mon Sep 17 00:00:00 2001 From: JinHo Kim <81083461+jinhokim98@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:33:16 +0900 Subject: [PATCH 18/20] =?UTF-8?q?fix:=20=EC=86=A1=EA=B8=88=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=8D=B0=EC=8A=A4=ED=81=AC=ED=83=91=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=9D=80=20=EB=B3=B5=EC=82=AC=ED=95=98=EA=B8=B0?= =?UTF-8?q?=EB=B0=96=EC=97=90=20=EB=B3=B4=EC=9D=B4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 데탑 기본 값을 복사하기로 변경 * fix: select box 클릭시 목록이 없어졌다가 생기는 오류 수정 --- .../components/Design/components/Select/Select.tsx | 4 ++-- .../components/Design/components/Select/useSelect.ts | 2 +- client/src/hooks/useSendPage.ts | 11 +++++++---- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/client/src/components/Design/components/Select/Select.tsx b/client/src/components/Design/components/Select/Select.tsx index 3f19d4e7d..860fa7edd 100644 --- a/client/src/components/Design/components/Select/Select.tsx +++ b/client/src/components/Design/components/Select/Select.tsx @@ -21,7 +21,7 @@ const Select = <T extends string | number | readonly string[]>({ return ( <ClickOutsideDetector targetRef={selectRef} onClickOutside={() => setIsOpen(false)}> - <fieldset css={selectStyle}> + <fieldset ref={selectRef} css={selectStyle}> <SelectInput labelText={labelText} placeholder={placeholder ?? ''} @@ -31,7 +31,7 @@ const Select = <T extends string | number | readonly string[]>({ setHasFocus={setIsOpen} /> {options.length > 0 && ( - <ul ref={selectRef} css={optionListStyle(theme, isOpen)}> + <ul css={optionListStyle(theme, isOpen)}> <Flex flexDirection="column" gap="0.5rem"> {options.map((option, index) => ( <li key={`${option}-${index}`}> diff --git a/client/src/components/Design/components/Select/useSelect.ts b/client/src/components/Design/components/Select/useSelect.ts index d0ca0ff84..886a1460e 100644 --- a/client/src/components/Design/components/Select/useSelect.ts +++ b/client/src/components/Design/components/Select/useSelect.ts @@ -9,7 +9,7 @@ const useSelect = <T extends string | number | readonly string[]>({defaultValue, const [isOpen, setIsOpen] = useState(false); const [value, setValue] = useState(defaultValue); - const selectRef = useRef<HTMLUListElement>(null); + const selectRef = useRef<HTMLFieldSetElement>(null); const handleSelect = (option: T) => { setValue(option); diff --git a/client/src/hooks/useSendPage.ts b/client/src/hooks/useSendPage.ts index d90c94403..ef1e9cf7d 100644 --- a/client/src/hooks/useSendPage.ts +++ b/client/src/hooks/useSendPage.ts @@ -1,6 +1,8 @@ import {useLocation} from 'react-router-dom'; import {useEffect, useState} from 'react'; +import {isMobileDevice} from '@utils/detectDevice'; + import {SendInfo} from './useReportsPage'; import toast from './useToast/toast'; import useAmplitude from './useAmplitude'; @@ -9,14 +11,15 @@ export type SendMethod = '복사하기' | '토스' | '카카오페이'; export type OnSend = () => void | Promise<void>; const useSendPage = () => { - const [sendMethod, setSendMethod] = useState<SendMethod>('토스'); + const isMobile = isMobileDevice(); + const options: SendMethod[] = isMobile ? ['토스', '카카오페이', '복사하기'] : ['복사하기']; + const defaultValue: SendMethod = isMobile ? '토스' : '복사하기'; + + const [sendMethod, setSendMethod] = useState<SendMethod>(defaultValue); const state = useLocation().state as SendInfo; const {trackSendMoney} = useAmplitude(); - const options: SendMethod[] = ['토스', '카카오페이', '복사하기']; - const defaultValue: SendMethod = '토스'; - const onSelect = (option: SendMethod) => { setSendMethod(option); }; From b61395ce3d2250f2a092b97afbc614ccaee28198 Mon Sep 17 00:00:00 2001 From: TaehunLee <85233397+Todari@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:43:35 +0900 Subject: [PATCH 19/20] =?UTF-8?q?=08fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=97=90=EC=84=9C=20=EB=B0=9C=EC=83=9D=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20(#737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 이미지 추가 페이지에서 발생하던 오류 수정 * design: 사진 추가하기 버튼 색상 변경 * fix: 사진이 없을 때, 홈에서 사진 조회를 할 수 없도록 변경 --- client/src/hooks/useAddImagesPage.ts | 27 +++++-------------- .../src/pages/AddImagesPage/AddImagesPage.tsx | 2 +- .../src/pages/EventPage/HomePage/HomePage.tsx | 8 +++--- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/client/src/hooks/useAddImagesPage.ts b/client/src/hooks/useAddImagesPage.ts index 917a109a3..4bc3f62de 100644 --- a/client/src/hooks/useAddImagesPage.ts +++ b/client/src/hooks/useAddImagesPage.ts @@ -34,30 +34,12 @@ const useAddImagesPage = () => { useEffect(() => { if (!isSuccess) return; - setImages([...prevImages]); - }, [prevImages, isSuccess]); - - useEffect(() => { - console.log(images); - }, [images]); + setImages([...prevImages, ...addedImages]); + }, [isSuccess, prevImages]); const handleChangeImages = (event: React.ChangeEvent<HTMLInputElement>) => { - //TODO: (@Todari): 현재 A 이미지 추가 -> A 이미지 x 버튼 눌러 취소 -> 다시 A 이미지 추가 시 업로드 되지 않음 - // event.target.files가 변경되지 않기 때문에 onChange에 넣은 - // handleChangeImages가 실행되지 않아 일어나는 문제로 추정 - if (event.target.files) { - const dataTransfer = new DataTransfer(); - - if (addedImages) { - Array.from(addedImages).forEach(image => dataTransfer.items.add(image)); - } - - Array.from(event.target.files).forEach(image => dataTransfer.items.add(image)); - - setImages([...prevImages, ...dataTransfer.files]); - } + setImages(prev => [...prev, ...(event.target.files ? Array.from(event.target.files) : [])]); }; - const handleDeleteImage = (index: number) => { if ('url' in images[index]) { //TODO: (@Todari): 추후 낙관적 업데이트 적용 @@ -67,6 +49,9 @@ const useAddImagesPage = () => { setIsPrevImageDeleted(true); } else { setImages(prev => prev.filter((_, idx) => idx !== index)); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } } }; diff --git a/client/src/pages/AddImagesPage/AddImagesPage.tsx b/client/src/pages/AddImagesPage/AddImagesPage.tsx index 60c87a4d9..e467437d3 100644 --- a/client/src/pages/AddImagesPage/AddImagesPage.tsx +++ b/client/src/pages/AddImagesPage/AddImagesPage.tsx @@ -35,7 +35,7 @@ const AddImagesPage = () => { onChange={handleChangeImages} style={{display: 'none'}} /> - <Button variants="primary" onClick={() => fileInputRef.current?.click()}> + <Button variants="tertiary" onClick={() => fileInputRef.current?.click()}> 사진 추가하기 </Button> </div> diff --git a/client/src/pages/EventPage/HomePage/HomePage.tsx b/client/src/pages/EventPage/HomePage/HomePage.tsx index 8cf15bc39..4314d9592 100644 --- a/client/src/pages/EventPage/HomePage/HomePage.tsx +++ b/client/src/pages/EventPage/HomePage/HomePage.tsx @@ -29,9 +29,11 @@ const HomePage = () => { title={eventName} amount={totalExpenseAmount} icon={ - <button> - <Icon iconType="photoButton" onClick={() => navigate(`/event/${eventId}/images`)} /> - </button> + images.length !== 0 && ( + <button> + <Icon iconType="photoButton" onClick={() => navigate(`/event/${eventId}/images`)} /> + </button> + ) } /> <Tabs> From 2234ba26f4f6a6dcad4f6f121135627648a73bac Mon Sep 17 00:00:00 2001 From: TaehunLee <85233397+Todari@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:43:55 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20eventName=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20og=ED=83=9C=EA=B7=B8=EA=B0=80=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: meta tag 수정 * feat: 행사 이름에 따라 meta tag가 변경되도록 수정 * fix: EventPageLayout이 사용되지 않는 경우, 기존 meta 태그가 보이도록 변경 * fix: unmount시 기존의 태그로 변경되도록 적용 * style: lint 적용 --- client/index.html | 7 ++----- client/src/pages/EventPage/EventPageLayout.tsx | 12 ++++++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/client/index.html b/client/index.html index 6ca0b92cb..7f96b23c5 100644 --- a/client/index.html +++ b/client/index.html @@ -5,7 +5,7 @@ <title>행동대장 - 쉽고 빠른 모임 정산 및 송금 서비스</title> - <meta name="description" content="모임에서 발생한 비용을 손쉽게 정산하고 간편하게 송금할 수 있는 행동대장" /> + <meta name="description" content="행동대장으로 모임에서 발생한 비용을 손쉽게 정산하고 간편하게 송금해요" /> <meta name="keywords" content="행동대장, 행대동장, 행댕이, 흔듯, 행사, 정산, 모임, 송금, 더치페이, 더치페이 서비스, 더치페이 앱, 간편 정산, 간편한 정산, 쉬운 정산, 정산 앱, 정산 서비스, 엔빵, 엔빵계산기, 엔빵 앱, 엔빵 서비스, 우아한테크코스, 우테코, 우테코 프로젝트, 우테코 6기, 우아한테크코스 6기" @@ -13,10 +13,7 @@ <meta property="og:url" content="https://haengdong.pro/" /> <meta property="og:title" content="행동대장 - 쉽고 빠른 모임 정산 및 송금 서비스" /> - <meta - property="og:description" - content="모임에서 발생한 비용을 실시간으로 기록하고 정산하여 더치페이 결과를 간편하게 송금할 수 있는 행동대장" - /> + <meta property="og:description" content="행동대장으로 모임에서 발생한 비용을 손쉽게 정산하고 간편하게 송금해요" /> <meta property="og:type" content="website" /> <meta property="og:image" diff --git a/client/src/pages/EventPage/EventPageLayout.tsx b/client/src/pages/EventPage/EventPageLayout.tsx index 6a16208d1..7361992b4 100644 --- a/client/src/pages/EventPage/EventPageLayout.tsx +++ b/client/src/pages/EventPage/EventPageLayout.tsx @@ -1,6 +1,7 @@ import type {Event} from 'types/serviceType'; import {Outlet} from 'react-router-dom'; +import {useEffect} from 'react'; import useEventPageLayout from '@hooks/useEventPageLayout'; import useShareEvent from '@hooks/useShareEvent'; @@ -12,6 +13,7 @@ import {DesktopShareEventButton, MobileShareEventButton} from '@components/Share import {Flex, Icon, IconButton, MainLayout, TopNav} from '@HDesign/index'; import {isMobileDevice} from '@utils/detectDevice'; +import {updateMetaTag} from '@utils/udpateMetaTag'; export type EventPageContextProps = Event & { isAdmin: boolean; @@ -40,6 +42,16 @@ const EventPageLayout = () => { kakaoShare(); }; + useEffect(() => { + console.log('mount'); + updateMetaTag('og:title', `행동대장이 "${eventSummary.eventName}"에 대한 정산을 요청했어요`); + + return () => { + console.log('unmount'); + updateMetaTag('og:title', '행동대장 - 쉽고 빠른 모임 정산 및 송금 서비스'); + }; + }, []); + return ( <MainLayout backgroundColor="gray"> <Flex justifyContent="spaceBetween" alignItems="center">