From 7625bdbf17e2e26115569834b53d0d76ea1b8873 Mon Sep 17 00:00:00 2001 From: ukkodeveloper Date: Wed, 27 Sep 2023 11:42:24 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Fix/#445=20preCheckAccessToken=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/shared/remotes/preCheckAccessToken.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/frontend/src/shared/remotes/preCheckAccessToken.ts b/frontend/src/shared/remotes/preCheckAccessToken.ts index 2a1d8b126..718e10bed 100644 --- a/frontend/src/shared/remotes/preCheckAccessToken.ts +++ b/frontend/src/shared/remotes/preCheckAccessToken.ts @@ -1,6 +1,4 @@ -import AuthError from '@/shared/remotes/AuthError'; import accessTokenStorage from '@/shared/utils/accessTokenStorage'; -import type { ErrorResponse } from '@/shared/remotes/index'; const isTokenExpiredAfter60seconds = (tokenExp: number) => { return tokenExp * 1000 - 30 * 1000 < Date.now(); @@ -31,13 +29,7 @@ const preCheckAccessToken = async () => { accessTokenStorage.setToken(accessToken); return accessToken; } - - const errorResponse: ErrorResponse = await response.json(); - - if (response.status === 401) { - throw new AuthError(errorResponse); - } - // 기타 상태코드 처리 + accessTokenStorage.removeToken(); } return null; From 771918bf8006ee1d72af19ebcf63729f4626935e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Wed, 27 Sep 2023 12:21:45 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Refactor/#463,=20#468=20=EC=BA=90=EB=9F=AC?= =?UTF-8?q?=EC=85=80=EC=9D=84=20=ED=81=B4=EB=A6=AD=ED=96=88=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9D=B4=20=EC=95=88=20?= =?UTF-8?q?=EB=90=9C=20=EA=B2=BD=EC=9A=B0=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=98=EB=8B=A4=EA=B3=A0=20=EB=9D=84?= =?UTF-8?q?=EC=9B=8C=EC=A4=80=EB=8B=A4=20(#467)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: PartCollectingPage에서 로그인 확인 과정 삭제 * feat: CarouselItem을 클릭시 로그인 확인 * feat: PartCollectingPage는 로그인해야 접근 가능하도록 변경 * feat: 킬링파트 등록페이지에서 닉네임과 문구 추가 * style: 킬링파트 등록 모달 제목 중앙정렬 * refactor: 불필요한 조건문 isLoggedIn 제거 * feat: GlobalStyle의 a태그에 cursor pointer 적용 --- .../songs/components/CarouselItem.tsx | 25 +++++++- .../songs/components/VoteInterface.tsx | 61 ++++++++++--------- frontend/src/router.tsx | 6 +- frontend/src/shared/styles/GlobalStyles.ts | 1 + 4 files changed, 60 insertions(+), 33 deletions(-) diff --git a/frontend/src/features/songs/components/CarouselItem.tsx b/frontend/src/features/songs/components/CarouselItem.tsx index e3d21da34..be5f3f8bc 100644 --- a/frontend/src/features/songs/components/CarouselItem.tsx +++ b/frontend/src/features/songs/components/CarouselItem.tsx @@ -1,6 +1,9 @@ -import { Link } from 'react-router-dom'; +import { useNavigate } from 'react-router-dom'; import { styled } from 'styled-components'; import emptyPlay from '@/assets/icon/empty-play.svg'; +import { useAuthContext } from '@/features/auth/components/AuthProvider'; +import LoginModal from '@/features/auth/components/LoginModal'; +import useModal from '@/shared/components/Modal/hooks/useModal'; import Spacing from '@/shared/components/Spacing'; import ROUTE_PATH from '@/shared/constants/path'; import { toMinSecText } from '@/shared/utils/convertTime'; @@ -13,9 +16,25 @@ interface CarouselItemProps { const CarouselItem = ({ votingSong }: CarouselItemProps) => { const { id, singer, title, videoLength, albumCoverUrl } = votingSong; + const { isOpen, openModal, closeModal } = useModal(); + + const { user } = useAuthContext(); + const isLoggedIn = !!user; + + const navigate = useNavigate(); + const goToPartCollectingPage = () => navigate(`${ROUTE_PATH.COLLECT}/${id}`); + return ( - + + + @@ -38,7 +57,7 @@ const Wrapper = styled.li` min-width: 350px; `; -const CollectingLink = styled(Link)` +const CollectingLink = styled.a` display: flex; justify-content: center; padding: 10px; diff --git a/frontend/src/features/songs/components/VoteInterface.tsx b/frontend/src/features/songs/components/VoteInterface.tsx index ad2e29855..8550ba630 100644 --- a/frontend/src/features/songs/components/VoteInterface.tsx +++ b/frontend/src/features/songs/components/VoteInterface.tsx @@ -1,6 +1,5 @@ import { styled } from 'styled-components'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; -import LoginModal from '@/features/auth/components/LoginModal'; import useVoteInterfaceContext from '@/features/songs/hooks/useVoteInterfaceContext'; import VideoSlider from '@/features/youtube/components/VideoSlider'; import useVideoPlayerContext from '@/features/youtube/hooks/useVideoPlayerContext'; @@ -19,10 +18,10 @@ const VoteInterface = () => { const { videoPlayer } = useVideoPlayerContext(); const { createKillingPart } = usePostKillingPart(); - const { user } = useAuthContext(); const { isOpen, openModal, closeModal } = useModal(); - const isLoggedIn = !!user; + const { user } = useAuthContext(); + const voteTimeText = toPlayingTimeText(partStartTime, partStartTime + interval); const submitKillingPart = async () => { @@ -40,39 +39,35 @@ const VoteInterface = () => { return ( 당신의 킬링파트를 등록하세요 + + 같은 파트에 대한 여러 번의 등록은 한 번의 등록으로 처리됩니다. - + 등록 - {isLoggedIn ? ( - - 킬링파트 등록을 완료했습니다. - - {voteTimeText} - 파트를 공유해 보세요😀 - - - - 확인 - - - 공유하기 - - - - ) : ( - - )} + + + + {user?.nickname}님의 + 킬링파트 등록을 완료했습니다. + + + {voteTimeText} + 파트를 공유해 보세요😀 + + + + 확인 + + + 공유하기 + + + ); }; @@ -110,6 +105,10 @@ const Register = styled.button` const ModalTitle = styled.h3``; +const TitleColumn = styled.div` + text-align: center; +`; + const ModalContent = styled.div` padding: 16px 0; @@ -147,3 +146,7 @@ const ButtonContainer = styled.div` gap: 16px; width: 100%; `; + +const Warning = styled.div` + color: ${({ theme: { color } }) => color.subText}; +`; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 33b267ea3..99e9b8af7 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -26,7 +26,11 @@ const router = createBrowserRouter([ }, { path: `${ROUTE_PATH.COLLECT}/:id`, - element: , + element: ( + + + + ), }, { path: `${ROUTE_PATH.SONG_DETAILS}/:id/:genre`, diff --git a/frontend/src/shared/styles/GlobalStyles.ts b/frontend/src/shared/styles/GlobalStyles.ts index 7249b30fa..b1f6e3913 100644 --- a/frontend/src/shared/styles/GlobalStyles.ts +++ b/frontend/src/shared/styles/GlobalStyles.ts @@ -54,6 +54,7 @@ const GlobalStyles = createGlobalStyle` } a { text-decoration: none; + cursor: pointer; } table { border-spacing: 0; From 3ed2f317fa170f700816c25f206d2e6eb0d5e1bd Mon Sep 17 00:00:00 2001 From: ukkodeveloper Date: Wed, 27 Sep 2023 13:13:10 +0900 Subject: [PATCH 3/4] =?UTF-8?q?Refactor/#443=20=EC=BA=90=EB=9F=AC=EC=85=80?= =?UTF-8?q?=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20(#454)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: isActive 속성에 $표시 추가 * feat: Thumbnail 컴포넌트 borderRadius 속성 추가 * refactor: 캐러셀 앨범 자켓을 Thumbnail 컴포넌트로 대체 * Fix/#445 preCheckAccessToken 로직 변경 * design: 캐러셀 아이템 재생 아이콘 높이 너비 스타일 추가 * design: 캐러셀 내 아이템 border radius 4px로 통일 --- .../features/songs/components/CarouselItem.tsx | 17 +++++++++-------- .../songs/components/CollectionCarousel.tsx | 8 ++++---- .../src/features/songs/components/Thumbnail.tsx | 9 +++++---- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/songs/components/CarouselItem.tsx b/frontend/src/features/songs/components/CarouselItem.tsx index be5f3f8bc..4221331c1 100644 --- a/frontend/src/features/songs/components/CarouselItem.tsx +++ b/frontend/src/features/songs/components/CarouselItem.tsx @@ -3,6 +3,7 @@ import { styled } from 'styled-components'; import emptyPlay from '@/assets/icon/empty-play.svg'; import { useAuthContext } from '@/features/auth/components/AuthProvider'; import LoginModal from '@/features/auth/components/LoginModal'; +import Thumbnail from '@/features/songs/components/Thumbnail'; import useModal from '@/shared/components/Modal/hooks/useModal'; import Spacing from '@/shared/components/Spacing'; import ROUTE_PATH from '@/shared/constants/path'; @@ -35,13 +36,13 @@ const CarouselItem = ({ votingSong }: CarouselItemProps) => { /> - + {title} {singer} - + {toMinSecText(videoLength)} @@ -63,12 +64,6 @@ const CollectingLink = styled.a` padding: 10px; `; -const Album = styled.img` - max-width: 120px; - background-color: white; - border-radius: 4px; -`; - const Contents = styled.div` display: flex; flex-direction: column; @@ -110,3 +105,9 @@ const PlayingTime = styled.div` const PlayingTimeText = styled.p` padding-top: 2px; `; + +const PlayIcon = styled.img` + width: 16px; + height: 16px; + margin: auto; +`; diff --git a/frontend/src/features/songs/components/CollectionCarousel.tsx b/frontend/src/features/songs/components/CollectionCarousel.tsx index 1a85b149d..81c214f25 100644 --- a/frontend/src/features/songs/components/CollectionCarousel.tsx +++ b/frontend/src/features/songs/components/CollectionCarousel.tsx @@ -38,7 +38,7 @@ const CollectionCarousel = ({ children }: CarouselProps) => { {Array.from({ length: numberOfItems }, (_, idx) => ( - + ))} @@ -91,10 +91,10 @@ const IndicatorWrapper = styled.div` background-color: transparent; `; -const Dot = styled.div<{ isActive: boolean }>` +const Dot = styled.div<{ $isActive: boolean }>` width: 8px; height: 8px; - background-color: ${({ isActive, theme: { color } }) => - isActive ? color.primary : color.secondary}; + background-color: ${({ $isActive, theme: { color } }) => + $isActive ? color.primary : color.secondary}; border-radius: 50%; `; diff --git a/frontend/src/features/songs/components/Thumbnail.tsx b/frontend/src/features/songs/components/Thumbnail.tsx index 5c487cfa3..9a90e106a 100644 --- a/frontend/src/features/songs/components/Thumbnail.tsx +++ b/frontend/src/features/songs/components/Thumbnail.tsx @@ -4,15 +4,16 @@ import type { ImgHTMLAttributes, SyntheticEvent } from 'react'; interface ThumbnailProps extends ImgHTMLAttributes { size?: Size; + borderRadius?: number; } -const Thumbnail = ({ size = 'lg', ...props }: ThumbnailProps) => { +const Thumbnail = ({ size = 'lg', borderRadius = 4, ...props }: ThumbnailProps) => { const insertDefaultJacket = ({ currentTarget }: SyntheticEvent) => { currentTarget.src = defaultAlbumJacket; }; return ( - + 노래 앨범 ); @@ -20,10 +21,10 @@ const Thumbnail = ({ size = 'lg', ...props }: ThumbnailProps) => { export default Thumbnail; -const Wrapper = styled.div<{ $size: Size }>` +const Wrapper = styled.div<{ $size: Size; $borderRadius: number }>` overflow: hidden; ${({ $size }) => SIZE_VARIANTS[$size]}; - border-radius: 4px; + border-radius: ${({ $borderRadius }) => $borderRadius}px; `; const SIZE_VARIANTS = { From e23e1c37e3796c212d5651a77bd2bf5a36d0baba Mon Sep 17 00:00:00 2001 From: ukkodeveloper Date: Wed, 27 Sep 2023 14:43:25 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Feat/#460=20GA=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: window에 ga 접근을 위해 타입 추가 * feat: ga event 발생시키는 함수 추가 * feat: like에 ga 등록 * fix: GA event 속성 중 필요한 것만 남기기 * feat: 노래 상세 페이지 ga 이벤트 추가 * feat: 마이페이지 ga 이벤트 추가 * feat: GA event 상수화 * feat: sendGAEvent 함수 인수별 default값 수정 * feat: 변경된 GA 함수 및 상수 적용 * feat: GA user memberId 가능한 반드시 수집하도록 변경 --- .../songs/components/KillingPartTrack.tsx | 26 +++++++++++++++- frontend/src/pages/MyPage.tsx | 30 +++++++++++++------ frontend/src/shared/constants/GAEventName.ts | 16 ++++++++++ .../src/shared/googleAnalytics/sendGAEvent.ts | 18 +++++++++++ frontend/src/shared/types/ga.d.ts | 3 ++ 5 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 frontend/src/shared/constants/GAEventName.ts create mode 100644 frontend/src/shared/googleAnalytics/sendGAEvent.ts create mode 100644 frontend/src/shared/types/ga.d.ts diff --git a/frontend/src/features/songs/components/KillingPartTrack.tsx b/frontend/src/features/songs/components/KillingPartTrack.tsx index 7d4a79819..2489e5ca1 100644 --- a/frontend/src/features/songs/components/KillingPartTrack.tsx +++ b/frontend/src/features/songs/components/KillingPartTrack.tsx @@ -9,6 +9,8 @@ import useVideoPlayerContext from '@/features/youtube/hooks/useVideoPlayerContex import useModal from '@/shared/components/Modal/hooks/useModal'; import useTimerContext from '@/shared/components/Timer/hooks/useTimerContext'; import useToastContext from '@/shared/components/Toast/hooks/useToastContext'; +import { GA_ACTIONS, GA_CATEGORIES } from '@/shared/constants/GAEventName'; +import sendGAEvent from '@/shared/googleAnalytics/sendGAEvent'; import { toPlayingTimeText } from '@/shared/utils/convertTime'; import copyClipboard from '@/shared/utils/copyClipBoard'; import formatOrdinals from '@/shared/utils/formatOrdinals'; @@ -49,6 +51,12 @@ const KillingPartTrack = ({ const partLength = end - start; const copyKillingPartUrl = async () => { + sendGAEvent({ + action: GA_ACTIONS.COPY_URL, + category: GA_CATEGORIES.SONG_DETAIL, + memberId: user?.memberId, + }); + await copyClipboard(partVideoUrl); showToast('영상 링크가 복사되었습니다.'); }; @@ -80,6 +88,12 @@ const KillingPartTrack = ({ }; const toggleTrackPlayAndStop = () => { + sendGAEvent({ + action: GA_ACTIONS.PLAY, + category: GA_CATEGORIES.SONG_DETAIL, + memberId: user?.memberId, + }); + if (isNowPlayingTrack) { stopTrack(); } else { @@ -87,6 +101,16 @@ const KillingPartTrack = ({ } }; + const toggleLike = () => { + sendGAEvent({ + action: GA_ACTIONS.LIKE, + category: GA_CATEGORIES.SONG_DETAIL, + memberId: user?.memberId, + }); + + toggleKillingPartLikes(); + }; + return ( diff --git a/frontend/src/pages/MyPage.tsx b/frontend/src/pages/MyPage.tsx index ad8c44744..fb146e76e 100644 --- a/frontend/src/pages/MyPage.tsx +++ b/frontend/src/pages/MyPage.tsx @@ -9,7 +9,9 @@ import Flex from '@/shared/components/Flex'; import Spacing from '@/shared/components/Spacing'; import SRHeading from '@/shared/components/SRHeading'; import useToastContext from '@/shared/components/Toast/hooks/useToastContext'; +import { GA_ACTIONS, GA_CATEGORIES } from '@/shared/constants/GAEventName'; import ROUTE_PATH from '@/shared/constants/path'; +import sendGAEvent from '@/shared/googleAnalytics/sendGAEvent'; import useFetch from '@/shared/hooks/useFetch'; import fetcher from '@/shared/remotes'; import { secondsToMinSec, toPlayingTimeText } from '@/shared/utils/convertTime'; @@ -38,11 +40,22 @@ const MyPage = () => { const navigate = useNavigate(); const logoutRedirect = () => { + sendGAEvent({ + action: GA_ACTIONS.LOGOUT, + category: GA_CATEGORIES.MY_PAGE, + memberId: user?.memberId, + }); logout(); navigate(ROUTE_PATH.ROOT); }; const goEditPage = () => { + sendGAEvent({ + action: GA_ACTIONS.EDIT_PROFILE, + category: GA_CATEGORIES.MY_PAGE, + memberId: user?.memberId, + }); + navigate(`/${ROUTE_PATH.EDIT_PROFILE}`); }; @@ -181,18 +194,17 @@ type LikePartItemProps = LikeKillingPart & { rank: number; }; -const LikePartItem = ({ - songId, - albumCoverUrl, - title, - singer, - // partId, - start, - end, -}: LikePartItemProps) => { +const LikePartItem = ({ songId, albumCoverUrl, title, singer, start, end }: LikePartItemProps) => { const { showToast } = useToastContext(); + const { user } = useAuthContext(); const shareUrl = () => { + sendGAEvent({ + action: GA_ACTIONS.COPY_URL, + category: GA_CATEGORIES.MY_PAGE, + memberId: user?.memberId, + }); + copyClipboard(`${BASE_URL?.replace('/api', '')}/songs/${songId}`); showToast('클립보드에 영상링크가 복사되었습니다.'); }; diff --git a/frontend/src/shared/constants/GAEventName.ts b/frontend/src/shared/constants/GAEventName.ts new file mode 100644 index 000000000..9d6019454 --- /dev/null +++ b/frontend/src/shared/constants/GAEventName.ts @@ -0,0 +1,16 @@ +export const GA_ACTIONS = { + COPY_URL: 'click_copy_part', + PLAY: 'click_play_part', + LIKE: 'click_like', + EDIT_PROFILE: 'click_edit_profile', + LOGOUT: 'click_logout', +}; + +export const GA_CATEGORIES = { + SONG_DETAIL: 'song_playing', + MY_PAGE: 'profile', +}; + +export const GA_MEMBER = { + NOT_LOGGED_IN: -1, +}; diff --git a/frontend/src/shared/googleAnalytics/sendGAEvent.ts b/frontend/src/shared/googleAnalytics/sendGAEvent.ts new file mode 100644 index 000000000..db6a74412 --- /dev/null +++ b/frontend/src/shared/googleAnalytics/sendGAEvent.ts @@ -0,0 +1,18 @@ +import { GA_MEMBER } from '@/shared/constants/GAEventName'; + +interface GAProps { + action: string; + category: string; + memberId?: number; +} + +export const sendGAEvent = ({ action, category, memberId = GA_MEMBER.NOT_LOGGED_IN }: GAProps) => { + if ('gtag' in window) { + window?.gtag('event', action, { + event_category: category, + member_id: memberId ? memberId : GA_MEMBER.NOT_LOGGED_IN, + }); + } +}; + +export default sendGAEvent; diff --git a/frontend/src/shared/types/ga.d.ts b/frontend/src/shared/types/ga.d.ts new file mode 100644 index 000000000..216fa2914 --- /dev/null +++ b/frontend/src/shared/types/ga.d.ts @@ -0,0 +1,3 @@ +interface Window { + gtag: typeof gtag; +}