From 86e9430e35f4c904e888a4362417a6fa078147b5 Mon Sep 17 00:00:00 2001 From: Leejin Yang Date: Fri, 6 Oct 2023 14:04:21 +0900 Subject: [PATCH] =?UTF-8?q?[FE]=20feat:=20GA=20=EC=9D=B4=EB=B2=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#721)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 상품 정렬 버튼 클릭 GA 이벤트 추가 * feat: 꿀조합 작성, 정렬 버튼 클릭 GA 이벤트 추가 * feat: 상품 목록 페이지 카테고리 버튼 클릭 GA 이벤트 추가 * feat: 상품 리뷰 정렬, 작성 버튼 클릭 GA 이벤트 추가 * feat: 꿀조합 등록하기 버튼 GA 이벤트 추가 * feat: 상품 리뷰 등록하기 버튼 클릭 GA 이벤트 추가 * feat: 검색 페이지에서 검색 GA 이벤트 추가 * style: 등록하기 버튼 클릭 -> 등록 * refactor: 리뷰, 꿀조합 등록 이벤트 삭제 * feat: 랭킹 링크 GA 이벤트 추가 * feat: 홈 배너 및 카테고리 링크 GA 이벤트 추가 --- .../CategoryFoodList/CategoryFoodList.tsx | 17 ++++++++++- .../CategoryFoodTab/CategoryFoodTab.tsx | 15 +++++++++- .../CategoryStoreList/CategoryStoreList.tsx | 17 ++++++++++- .../CategoryStoreTab/CategoryStoreTab.tsx | 15 +++++++++- .../ProductRankingList/ProductRankingList.tsx | 12 +++++++- .../RecipeRankingList/RecipeRankingList.tsx | 8 ++++- .../ReviewRankingList/ReviewRankingList.tsx | 7 +++++ .../RecipeRegisterForm/RecipeRegisterForm.tsx | 2 +- frontend/src/hooks/common/index.ts | 1 + frontend/src/hooks/common/useGA.ts | 20 +++++++++++++ frontend/src/hooks/search/useSearch.ts | 5 ++++ frontend/src/pages/HomePage.tsx | 12 +++++++- frontend/src/pages/ProductDetailPage.tsx | 30 ++++++++----------- frontend/src/pages/ProductListPage.tsx | 10 +++++-- frontend/src/pages/RecipePage.tsx | 5 +++- frontend/src/utils/category.ts | 4 +++ 16 files changed, 151 insertions(+), 29 deletions(-) create mode 100644 frontend/src/hooks/common/useGA.ts create mode 100644 frontend/src/utils/category.ts diff --git a/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx b/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx index ec2e95c6b..46464b231 100644 --- a/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx +++ b/frontend/src/components/Common/CategoryFoodList/CategoryFoodList.tsx @@ -5,18 +5,33 @@ import styled from 'styled-components'; import CategoryItem from '../CategoryItem/CategoryItem'; import { CATEGORY_TYPE } from '@/constants'; +import { useGA } from '@/hooks/common'; import { useCategoryFoodQuery } from '@/hooks/queries/product'; const category = CATEGORY_TYPE.FOOD; const CategoryFoodList = () => { const { data: categories } = useCategoryFoodQuery(category); + const { gaEvent } = useGA(); + + const handleHomeCategoryLinkClick = (categoryName: string) => { + gaEvent({ + category: 'link', + action: `${categoryName} 카테고리 링크 클릭`, + label: '카테고리', + }); + }; return (
{categories.map((menu) => ( - + handleHomeCategoryLinkClick(menu.name)} + > ))} diff --git a/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx b/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx index 1e672994b..6b6a734bc 100644 --- a/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx +++ b/frontend/src/components/Common/CategoryFoodTab/CategoryFoodTab.tsx @@ -4,8 +4,10 @@ import { useLocation } from 'react-router-dom'; import styled from 'styled-components'; import { CATEGORY_TYPE } from '@/constants'; +import { useGA } from '@/hooks/common'; import { useCategoryActionContext, useCategoryValueContext } from '@/hooks/context'; import { useCategoryFoodQuery } from '@/hooks/queries/product/useCategoryQuery'; +import { getTargetCategoryName } from '@/utils/category'; const category = CATEGORY_TYPE.FOOD; @@ -20,12 +22,23 @@ const CategoryFoodTab = () => { const queryParams = new URLSearchParams(location.search); const categoryIdFromURL = queryParams.get('category'); + const { gaEvent } = useGA(); + useEffect(() => { if (categoryIdFromURL) { selectCategory(category, parseInt(categoryIdFromURL)); } }, [category]); + const handleCategoryButtonClick = (menuId: number) => { + selectCategory(category, menuId); + gaEvent({ + category: 'button', + action: `${getTargetCategoryName(categories, menuId)} 카테고리 버튼 클릭`, + label: '카테고리', + }); + }; + return ( {categories.map((menu) => { @@ -40,7 +53,7 @@ const CategoryFoodTab = () => { weight="bold" variant={isSelected ? 'filled' : 'outlined'} isSelected={isSelected} - onClick={() => selectCategory(category, menu.id)} + onClick={() => handleCategoryButtonClick(menu.id)} aria-pressed={isSelected} > {menu.name} diff --git a/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx b/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx index 46f4958c1..9ce856fa1 100644 --- a/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx +++ b/frontend/src/components/Common/CategoryStoreList/CategoryStoreList.tsx @@ -5,18 +5,33 @@ import styled from 'styled-components'; import CategoryItem from '../CategoryItem/CategoryItem'; import { CATEGORY_TYPE } from '@/constants'; +import { useGA } from '@/hooks/common'; import { useCategoryStoreQuery } from '@/hooks/queries/product'; const category = CATEGORY_TYPE.STORE; const CategoryStoreList = () => { const { data: categories } = useCategoryStoreQuery(category); + const { gaEvent } = useGA(); + + const handleHomeCategoryLinkClick = (categoryName: string) => { + gaEvent({ + category: 'link', + action: `${categoryName} 카테고리 링크 클릭`, + label: '카테고리', + }); + }; return (
{categories.map((menu) => ( - + handleHomeCategoryLinkClick(menu.name)} + > ))} diff --git a/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx b/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx index b93d83ab0..a83fa501f 100644 --- a/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx +++ b/frontend/src/components/Common/CategoryStoreTab/CategoryStoreTab.tsx @@ -4,8 +4,10 @@ import { useLocation } from 'react-router-dom'; import styled from 'styled-components'; import { CATEGORY_TYPE } from '@/constants'; +import { useGA } from '@/hooks/common'; import { useCategoryActionContext, useCategoryValueContext } from '@/hooks/context'; import { useCategoryStoreQuery } from '@/hooks/queries/product/useCategoryQuery'; +import { getTargetCategoryName } from '@/utils/category'; const category = CATEGORY_TYPE.STORE; @@ -20,12 +22,23 @@ const CategoryStoreTab = () => { const queryParams = new URLSearchParams(location.search); const categoryIdFromURL = queryParams.get('category'); + const { gaEvent } = useGA(); + useEffect(() => { if (categoryIdFromURL) { selectCategory(category, parseInt(categoryIdFromURL)); } }, [category]); + const handleCategoryButtonClick = (menuId: number) => { + selectCategory(category, menuId); + gaEvent({ + category: 'button', + action: `${getTargetCategoryName(categories, menuId)} 카테고리 버튼 클릭`, + label: '카테고리', + }); + }; + return ( {categories.map((menu) => { @@ -40,7 +53,7 @@ const CategoryStoreTab = () => { weight="bold" variant={isSelected ? 'filled' : 'outlined'} isSelected={isSelected} - onClick={() => selectCategory(category, menu.id)} + onClick={() => handleCategoryButtonClick(menu.id)} aria-pressed={isSelected} > {menu.name} diff --git a/frontend/src/components/Rank/ProductRankingList/ProductRankingList.tsx b/frontend/src/components/Rank/ProductRankingList/ProductRankingList.tsx index 08e37207b..309f52192 100644 --- a/frontend/src/components/Rank/ProductRankingList/ProductRankingList.tsx +++ b/frontend/src/components/Rank/ProductRankingList/ProductRankingList.tsx @@ -3,6 +3,7 @@ import { Link as RouterLink } from 'react-router-dom'; import { ProductOverviewItem } from '@/components/Product'; import { PATH } from '@/constants/path'; +import { useGA } from '@/hooks/common'; import { useProductRankingQuery } from '@/hooks/queries/rank'; import displaySlice from '@/utils/displaySlice'; @@ -12,13 +13,22 @@ interface ProductRankingListProps { const ProductRankingList = ({ isHomePage = false }: ProductRankingListProps) => { const { data: productRankings } = useProductRankingQuery(); + const { gaEvent } = useGA(); const productsToDisplay = displaySlice(isHomePage, productRankings.products, 3); + const handleProductRankingLinkClick = () => { + gaEvent({ category: 'link', action: '상품 랭킹 링크 클릭', label: '랭킹' }); + }; + return (
    {productsToDisplay.map(({ id, name, image, categoryType }, index) => (
  • - + diff --git a/frontend/src/components/Rank/RecipeRankingList/RecipeRankingList.tsx b/frontend/src/components/Rank/RecipeRankingList/RecipeRankingList.tsx index 37c326c75..76397964d 100644 --- a/frontend/src/components/Rank/RecipeRankingList/RecipeRankingList.tsx +++ b/frontend/src/components/Rank/RecipeRankingList/RecipeRankingList.tsx @@ -5,17 +5,23 @@ import RecipeRankingItem from '../RecipeRankingItem/RecipeRankingItem'; import { Carousel } from '@/components/Common'; import { PATH } from '@/constants/path'; +import { useGA } from '@/hooks/common'; import { useRecipeRankingQuery } from '@/hooks/queries/rank'; const RecipeRankingList = () => { const { data: recipeResponse } = useRecipeRankingQuery(); + const { gaEvent } = useGA(); if (recipeResponse.recipes.length === 0) return 아직 랭킹이 없어요!; + const handleRecipeRankingLinkClick = () => { + gaEvent({ category: 'link', action: '꿀조합 랭킹 링크 클릭', label: '랭킹' }); + }; + const carouselList = recipeResponse.recipes.map((recipe, index) => ({ id: index, children: ( - + ), diff --git a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx index 9621c40ce..7b6c8272c 100644 --- a/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx +++ b/frontend/src/components/Rank/ReviewRankingList/ReviewRankingList.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import ReviewRankingItem from '../ReviewRankingItem/ReviewRankingItem'; import { PATH } from '@/constants/path'; +import { useGA } from '@/hooks/common'; import { useReviewRankingQuery } from '@/hooks/queries/rank'; import useDisplaySlice from '@/utils/displaySlice'; @@ -14,8 +15,13 @@ interface ReviewRankingListProps { const ReviewRankingList = ({ isHomePage = false }: ReviewRankingListProps) => { const { data: reviewRankings } = useReviewRankingQuery(); + const { gaEvent } = useGA(); const reviewsToDisplay = useDisplaySlice(isHomePage, reviewRankings.reviews); + const handleReviewRankingLinkClick = () => { + gaEvent({ category: 'link', action: '리뷰 랭킹 링크 클릭', label: '랭킹' }); + }; + return ( {reviewsToDisplay.map((reviewRanking) => ( @@ -23,6 +29,7 @@ const ReviewRankingList = ({ isHomePage = false }: ReviewRankingListProps) => { diff --git a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx index 85caf35ca..238de3574 100644 --- a/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx +++ b/frontend/src/components/Recipe/RecipeRegisterForm/RecipeRegisterForm.tsx @@ -91,7 +91,7 @@ const RecipeRegisterForm = ({ closeRecipeDialog }: RecipeRegisterFormProps) => { - 레시피 등록하기 + 꿀조합 등록하기 diff --git a/frontend/src/hooks/common/index.ts b/frontend/src/hooks/common/index.ts index 5c0ce9f37..2cbf28c1f 100644 --- a/frontend/src/hooks/common/index.ts +++ b/frontend/src/hooks/common/index.ts @@ -10,3 +10,4 @@ export { default as useTimeout } from './useTimeout'; export { default as useRouteChangeTracker } from './useRouteChangeTracker'; export { default as useTabMenu } from './useTabMenu'; export { default as useScrollRestoration } from './useScrollRestoration'; +export { default as useGA } from './useGA'; diff --git a/frontend/src/hooks/common/useGA.ts b/frontend/src/hooks/common/useGA.ts new file mode 100644 index 000000000..2a347e299 --- /dev/null +++ b/frontend/src/hooks/common/useGA.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; +import ReactGA from 'react-ga4'; + +interface GAEventProps { + category: string; + action: string; + label?: string; +} + +const useGA = () => { + // TODO: navigate event tracking + + const gaEvent = useCallback((eventProps: GAEventProps) => { + ReactGA.event(eventProps); + }, []); + + return { gaEvent }; +}; + +export default useGA; diff --git a/frontend/src/hooks/search/useSearch.ts b/frontend/src/hooks/search/useSearch.ts index 5405ecfc3..4c438d4ec 100644 --- a/frontend/src/hooks/search/useSearch.ts +++ b/frontend/src/hooks/search/useSearch.ts @@ -2,6 +2,8 @@ import type { ChangeEventHandler, FormEventHandler, MouseEventHandler } from 're import { useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { useGA } from '../common'; + const useSearch = () => { const inputRef = useRef(null); @@ -12,6 +14,8 @@ const useSearch = () => { const [isSubmitted, setIsSubmitted] = useState(!!currentSearchQuery); const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(searchQuery.length > 0); + const { gaEvent } = useGA(); + const focusInput = () => { if (inputRef.current) { inputRef.current.focus(); @@ -26,6 +30,7 @@ const useSearch = () => { const handleSearch: FormEventHandler = (event) => { event.preventDefault(); + gaEvent({ category: 'submit', action: '검색 페이지에서 검색', label: '검색' }); const trimmedSearchQuery = searchQuery.trim(); diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 4365aa186..6d8357ba1 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -6,10 +6,12 @@ import styled from 'styled-components'; import { Loading, ErrorBoundary, ErrorComponent, CategoryFoodList, CategoryStoreList } from '@/components/Common'; import { ProductRankingList, ReviewRankingList, RecipeRankingList } from '@/components/Rank'; import { IMAGE_URL } from '@/constants'; +import { useGA } from '@/hooks/common'; import channelTalk from '@/service/channelTalk'; export const HomePage = () => { const { reset } = useQueryErrorResetBoundary(); + const { gaEvent } = useGA(); channelTalk.loadScript(); @@ -17,10 +19,18 @@ export const HomePage = () => { pluginKey: process.env.CHANNEL_TALK_KEY ?? '', }); + const handleBannerClick = () => { + gaEvent({ category: 'link', action: '이벤트 배너 클릭', label: '배너' }); + }; + return ( <>
    - +
    diff --git a/frontend/src/pages/ProductDetailPage.tsx b/frontend/src/pages/ProductDetailPage.tsx index e216ea9a3..46cc74b18 100644 --- a/frontend/src/pages/ProductDetailPage.tsx +++ b/frontend/src/pages/ProductDetailPage.tsx @@ -1,7 +1,6 @@ import { BottomSheet, Spacing, useBottomSheet, Text, Link } from '@fun-eat/design-system'; import { useQueryErrorResetBoundary } from '@tanstack/react-query'; import { useState, useRef, Suspense } from 'react'; -import ReactGA from 'react-ga4'; import { useParams, Link as RouterLink } from 'react-router-dom'; import styled from 'styled-components'; @@ -21,7 +20,7 @@ import { ReviewList, ReviewRegisterForm } from '@/components/Review'; import { RECIPE_SORT_OPTIONS, REVIEW_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import ReviewFormProvider from '@/contexts/ReviewFormContext'; -import { useSortOption, useTabMenu } from '@/hooks/common'; +import { useGA, useSortOption, useTabMenu } from '@/hooks/common'; import { useMemberQuery } from '@/hooks/queries/members'; import { useProductDetailQuery } from '@/hooks/queries/product'; @@ -42,41 +41,40 @@ export const ProductDetailPage = () => { const { selectedOption, selectSortOption } = useSortOption(REVIEW_SORT_OPTIONS[0]); const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const [activeSheet, setActiveSheet] = useState<'registerReview' | 'sortOption'>('sortOption'); + const { gaEvent } = useGA(); const productDetailPageRef = useRef(null); - const tabMenus = [`리뷰 ${productDetail.reviewCount}`, '꿀조합']; - const sortOptions = isReviewTab ? REVIEW_SORT_OPTIONS : RECIPE_SORT_OPTIONS; - const currentSortOption = isReviewTab ? REVIEW_SORT_OPTIONS[0] : RECIPE_SORT_OPTIONS[0]; - if (!category) { return null; } + const { name, bookmark, reviewCount } = productDetail; + + const tabMenus = [`리뷰 ${reviewCount}`, '꿀조합']; + const sortOptions = isReviewTab ? REVIEW_SORT_OPTIONS : RECIPE_SORT_OPTIONS; + const currentSortOption = isReviewTab ? REVIEW_SORT_OPTIONS[0] : RECIPE_SORT_OPTIONS[0]; + const handleOpenRegisterReviewSheet = () => { setActiveSheet('registerReview'); handleOpenBottomSheet(); + gaEvent({ category: 'button', action: '상품 리뷰 작성하기 버튼 클릭', label: '상품 리뷰 작성' }); }; const handleOpenSortOptionSheet = () => { setActiveSheet('sortOption'); handleOpenBottomSheet(); + gaEvent({ category: 'button', action: '상품 리뷰 정렬 버튼 클릭', label: '상품 리뷰 정렬' }); }; const handleTabMenuSelect = (index: number) => { handleTabMenuClick(index); selectSortOption(currentSortOption); - - ReactGA.event({ - category: '버튼', - action: '카테고리 이동 클릭 액션', - label: 'category', - }); }; return ( - + @@ -96,11 +94,7 @@ export const ProductDetailPage = () => { {isReviewTab ? ( ) : ( - + )} diff --git a/frontend/src/pages/ProductListPage.tsx b/frontend/src/pages/ProductListPage.tsx index d98c79ddc..11cf6231b 100644 --- a/frontend/src/pages/ProductListPage.tsx +++ b/frontend/src/pages/ProductListPage.tsx @@ -18,7 +18,7 @@ import { ProductTitle, ProductList } from '@/components/Product'; import { PRODUCT_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import type { CategoryIds } from '@/contexts/CategoryContext'; -import { useScrollRestoration, useSortOption } from '@/hooks/common'; +import { useGA, useScrollRestoration, useSortOption } from '@/hooks/common'; import { useCategoryValueContext } from '@/hooks/context'; import { isCategoryVariant } from '@/types/common'; @@ -31,6 +31,7 @@ export const ProductListPage = () => { const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { selectedOption, selectSortOption } = useSortOption(PRODUCT_SORT_OPTIONS[0]); const { reset } = useQueryErrorResetBoundary(); + const { gaEvent } = useGA(); const { categoryIds } = useCategoryValueContext(); @@ -40,6 +41,11 @@ export const ProductListPage = () => { return null; } + const handleSortButtonClick = () => { + handleOpenBottomSheet(); + gaEvent({ category: 'button', action: '상품 정렬 버튼 클릭', label: '상품 정렬' }); + }; + return ( <> @@ -54,7 +60,7 @@ export const ProductListPage = () => { }> - + diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index 3d72fdf78..fe7dc1a36 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -18,7 +18,7 @@ import { RecipeList, RecipeRegisterForm } from '@/components/Recipe'; import { RECIPE_SORT_OPTIONS } from '@/constants'; import { PATH } from '@/constants/path'; import RecipeFormProvider from '@/contexts/RecipeFormContext'; -import { useSortOption } from '@/hooks/common'; +import { useGA, useSortOption } from '@/hooks/common'; const RECIPE_PAGE_TITLE = '🍯 꿀조합'; const REGISTER_RECIPE = '꿀조합 작성하기'; @@ -29,17 +29,20 @@ export const RecipePage = () => { const { selectedOption, selectSortOption } = useSortOption(RECIPE_SORT_OPTIONS[0]); const { ref, isClosing, handleOpenBottomSheet, handleCloseBottomSheet } = useBottomSheet(); const { reset } = useQueryErrorResetBoundary(); + const { gaEvent } = useGA(); const recipeRef = useRef(null); const handleOpenRegisterRecipeSheet = () => { setActiveSheet('registerRecipe'); handleOpenBottomSheet(); + gaEvent({ category: 'button', action: '꿀조합 작성하기 버튼 클릭', label: '꿀조합 작성' }); }; const handleOpenSortOptionSheet = () => { setActiveSheet('sortOption'); handleOpenBottomSheet(); + gaEvent({ category: 'button', action: '꿀조합 정렬 버튼 클릭', label: '꿀조합 정렬' }); }; return ( diff --git a/frontend/src/utils/category.ts b/frontend/src/utils/category.ts new file mode 100644 index 000000000..a7a9ea23f --- /dev/null +++ b/frontend/src/utils/category.ts @@ -0,0 +1,4 @@ +import type { Category } from '@/types/common'; + +export const getTargetCategoryName = (categories: Category[], id: number) => + categories.find((category) => category.id === id)?.name;