diff --git a/apps/web-user/src/app/auth/login/google/page.tsx b/apps/web-user/src/app/auth/login/google/page.tsx index 3a408b4d..2d522b17 100644 --- a/apps/web-user/src/app/auth/login/google/page.tsx +++ b/apps/web-user/src/app/auth/login/google/page.tsx @@ -8,6 +8,9 @@ import { AUTH_ERROR_MESSAGES } from "@/apps/web-user/features/auth/constants/aut import { PATHS } from "@/apps/web-user/common/constants/paths.constant"; import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; +import { decodeJwtPayload } from "@/apps/web-user/features/auth/utils/jwt.util"; +import { resolveSocialAuthFailReason } from "@/apps/web-user/features/auth/utils/social-auth-error.util"; /** * 구글 OAuth 리다이렉트 콜백 — `code`로 `/v1/consumer/auth/google/login` 호출 @@ -22,13 +25,21 @@ function GoogleAuthCallbackContent() { useEffect(() => { const code = searchParams.get("code"); if (!code) { + // 구글 인증 화면에서 사용자가 취소한 경우 code 없이 리다이렉트됨 + trackEvent("fail_social_auth", { provider: "google", fail_reason: "cancel" }); router.replace(PATHS.HOME); return; } const run = async () => { + trackEvent("request_social_auth", { provider: "google" }); + try { const data = await authApi.googleLogin(code); + const userId = decodeJwtPayload<{ sub: string }>(data.accessToken)?.sub; + if (userId) { + trackEvent("success_login", { provider: "google", user_id: userId }); + } login(data.accessToken); router.replace(PATHS.HOME); } catch (error: unknown) { @@ -49,6 +60,10 @@ function GoogleAuthCallbackContent() { params.set("googleEmail", googleEmail); router.replace(`${PATHS.AUTH.GOOGLE_REGISTER}?${params.toString()}`); } else { + trackEvent("fail_social_auth", { + provider: "google", + fail_reason: resolveSocialAuthFailReason(error), + }); router.replace(PATHS.HOME); showAlert({ type: "error", diff --git a/apps/web-user/src/app/auth/login/kakao/page.tsx b/apps/web-user/src/app/auth/login/kakao/page.tsx index c5db26e5..2a7a8256 100644 --- a/apps/web-user/src/app/auth/login/kakao/page.tsx +++ b/apps/web-user/src/app/auth/login/kakao/page.tsx @@ -8,6 +8,9 @@ import { AUTH_ERROR_MESSAGES } from "@/apps/web-user/features/auth/constants/aut import { PATHS } from "@/apps/web-user/common/constants/paths.constant"; import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; +import { decodeJwtPayload } from "@/apps/web-user/features/auth/utils/jwt.util"; +import { resolveSocialAuthFailReason } from "@/apps/web-user/features/auth/utils/social-auth-error.util"; function KakaoAuthCallbackContent() { const router = useRouter(); @@ -18,13 +21,21 @@ function KakaoAuthCallbackContent() { useEffect(() => { const code = searchParams.get("code"); if (!code) { + // 카카오 인증 화면에서 사용자가 취소한 경우 code 없이 리다이렉트됨 + trackEvent("fail_social_auth", { provider: "kakao", fail_reason: "cancel" }); router.replace(PATHS.HOME); return; } const run = async () => { + trackEvent("request_social_auth", { provider: "kakao" }); + try { const data = await authApi.kakaoLogin(code); + const userId = decodeJwtPayload<{ sub: string }>(data.accessToken)?.sub; + if (userId) { + trackEvent("success_login", { provider: "kakao", user_id: userId }); + } login(data.accessToken); router.replace(PATHS.HOME); } catch (error: unknown) { @@ -41,6 +52,10 @@ function KakaoAuthCallbackContent() { params.set("kakaoEmail", kakaoEmail); router.replace(`${PATHS.AUTH.KAKAO_REGISTER}?${params.toString()}`); } else { + trackEvent("fail_social_auth", { + provider: "kakao", + fail_reason: resolveSocialAuthFailReason(error), + }); router.replace(PATHS.HOME); showAlert({ type: "error", diff --git a/apps/web-user/src/app/map/MapPageClient.tsx b/apps/web-user/src/app/map/MapPageClient.tsx index b1879f60..6af20340 100644 --- a/apps/web-user/src/app/map/MapPageClient.tsx +++ b/apps/web-user/src/app/map/MapPageClient.tsx @@ -60,6 +60,8 @@ import { isStoreOpenOnSeoulCalendarDay, storeCalendarOverlapsMapPickupHalfDay, } from "@/apps/web-user/features/store/utils/store-business-calendar.util"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; +import type { MapAreaSearchTrigger } from "@/apps/web-user/common/types/analytics.type"; declare global { interface Window { @@ -86,6 +88,18 @@ export default function MapPageClient() { const [pickupCalendarOpen, setPickupCalendarOpen] = useState(false); const [mapListFilterPanelOpen, setMapListFilterPanelOpen] = useState(false); + // 지도 화면 노출 + useEffect(() => { + trackEvent("view_map"); + }, []); + + // 마지막 지도 이동 트리거 (드래그/줌/현재위치) — success_map_area_search용 + const lastMapGestureRef = useRef(null); + // 필터 적용 직후 다음 스토어 개수 갱신을 success_filter_apply로 처리하기 위한 플래그 + const pendingFilterApplyRef = useRef(false); + // view_map_search_result 중복 발화 방지 + const lastViewedMapSearchKeyRef = useRef(null); + /** URL에 픽업이 있으면 상태에 반영 (검색 페이지 등에서 돌아올 때) */ useEffect(() => { const hasPickupInUrl = @@ -105,6 +119,10 @@ export default function MapPageClient() { const handlePickupConfirm = useCallback( (f: MapPickupFilter) => { + trackEvent("success_pickup_date_select", { + selected_date: f.date.toISOString(), + selected_time_slot: f.kind, + }); setPickupFilter(f); applyPickupToUrl(f); }, @@ -322,6 +340,7 @@ export default function MapPageClient() { entries.set(store.id, entry); window.kakao.maps.event.addListener(marker, "click", () => { + trackEvent("engage_store_pin", { store_id: entry.store.id }); if (markerImageRef.current) kakaoMarkerEntriesRef.current.forEach((e) => e.marker.setImage(markerImageRef.current)); resetPlatformMarkerImages(); @@ -437,6 +456,7 @@ export default function MapPageClient() { image: markerImageRef.current, }); window.kakao.maps.event.addListener(marker, "click", () => { + trackEvent("engage_store_pin", { store_id: "none" }); setSelectedStore(null); setSelectedUnenteredStore({ kakaoPlaceId: String(place.id ?? ""), @@ -607,12 +627,14 @@ export default function MapPageClient() { // 제스처(드래그·줌) 진행 여부 추적 — 제스처 중에는 마커 DOM 갱신을 전부 보류해 터치 끊김을 방지 window.kakao.maps.event.addListener(map, "dragstart", () => { + lastMapGestureRef.current = "drag"; isUserInteractingRef.current = true; }); window.kakao.maps.event.addListener(map, "dragend", () => { isUserInteractingRef.current = false; }); window.kakao.maps.event.addListener(map, "zoom_start", () => { + lastMapGestureRef.current = "zoom"; isUserInteractingRef.current = true; }); window.kakao.maps.event.addListener(map, "zoom_changed", () => { @@ -640,6 +662,11 @@ export default function MapPageClient() { applyKeywordSearchResultsRef.current(pending); } drawPlatformStoreMarkersRef.current(); + trackEvent("success_map_area_search", { + trigger_type: lastMapGestureRef.current ?? "drag", + result_count: getStoresForListRef.current().length, + }); + lastMapGestureRef.current = null; const allowKakaoUnopened = searchStoresRef.current === null && !searchQueryRef.current && @@ -730,9 +757,18 @@ export default function MapPageClient() { ) { searchPlaces(map.getCenter()); } + // "내 위치로 이동" 버튼으로 명시적으로 재요청한 경우에만 재검색 완료 이벤트 전송 + // (최초 진입 시 자동 중심 이동은 사용자 액션이 아니므로 제외) + if (lastMapGestureRef.current === "current_location") { + trackEvent("success_map_area_search", { + trigger_type: "current_location", + result_count: getStoresToShow(map).length, + }); + lastMapGestureRef.current = null; + } } usedUserLocationForCenterRef.current = true; - }, [userLocation, searchQuery, pickupFilter, searchPlaces]); + }, [userLocation, searchQuery, pickupFilter, searchPlaces, getStoresToShow]); // 현재위치 변경 또는 지도 준비 완료 시 현재위치 마커(점) 갱신 useEffect(() => { @@ -756,6 +792,17 @@ export default function MapPageClient() { if (listSheetPanelOffsetRef.current > 0) { setListSheetStores(getStoresForList()); } + // 필터 적용 직후였다면, 새로 조회된 스토어 개수로 필터 적용 완료 이벤트 전송 + if (pendingFilterApplyRef.current) { + pendingFilterApplyRef.current = false; + trackEvent("success_filter_apply", { + size_filter: listFilter.sizes?.join(",") || undefined, + price_min: listFilter.minPrice, + price_max: listFilter.maxPrice, + type_filter: listFilter.productCategoryTypes?.join(",") || undefined, + result_count: platformStores.length, + }); + } }, [ platformStores, drawPlatformStoreMarkers, @@ -793,6 +840,13 @@ export default function MapPageClient() { const stores = filterStoresWithCoordinates(res.data ?? []); if (cancelled) return; searchStoresRef.current = stores; + if (lastViewedMapSearchKeyRef.current !== searchQuery) { + lastViewedMapSearchKeyRef.current = searchQuery; + trackEvent("view_map_search_result", { + keyword: searchQuery, + result_count: stores.length, + }); + } const map = mapInstanceRef.current; if (!map || !window.kakao?.maps) return; clearKakaoMarkers(); // 검색 모드 진입 시 기존 미입점 마커 제거 @@ -875,6 +929,8 @@ export default function MapPageClient() { // ---- Handlers ---- /** 내 위치 버튼: 현재위치 재요청 후 다음 effect에서 지도 중심 이동 */ const handleRefreshLocation = () => { + trackEvent("engage_current_location"); + lastMapGestureRef.current = "current_location"; usedUserLocationForCenterRef.current = false; refreshUserLocation(); }; @@ -937,7 +993,10 @@ export default function MapPageClient() { setPickupCalendarOpen(true)} + onCalendarClick={() => { + trackEvent("engage_date_picker_open"); + setPickupCalendarOpen(true); + }} onPickupClear={handlePickupClear} onSearchBackClick={() => router.push(buildMapSearchUrl(pickupFilter))} onSearchEditClick={() => @@ -1039,7 +1098,10 @@ export default function MapPageClient() { sortBy={listSortBy} onSortByChange={setListSortBy} listFilter={listFilter} - onListFilterChange={setListFilter} + onListFilterChange={(f) => { + pendingFilterApplyRef.current = true; + setListFilter(f); + }} onFilterPanelOpenChange={setMapListFilterPanelOpen} /> )} diff --git a/apps/web-user/src/app/map/search/page.tsx b/apps/web-user/src/app/map/search/page.tsx index 37aa6742..e2578fff 100644 --- a/apps/web-user/src/app/map/search/page.tsx +++ b/apps/web-user/src/app/map/search/page.tsx @@ -14,6 +14,7 @@ import { MAP_PICKUP_URL_PERIOD_KEY, type MapPickupFilter, } from "@/apps/web-user/features/store/utils/map.util"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; const RECENT_SEARCHES_KEY = "recentSearches"; const MAX_RECENT = 10; @@ -90,6 +91,7 @@ export default function MapSearchPage() { const handleSearch = (term: string) => { if (!term.trim()) return; + trackEvent("request_map_search", { keyword: term.trim() }); saveRecentSearch(term.trim()); setSearchTerm(term.trim()); router.push(buildMapPageUrl(term.trim(), pickupFilter)); diff --git a/apps/web-user/src/app/mypage/order/page.tsx b/apps/web-user/src/app/mypage/order/page.tsx index 541b2144..8127320d 100644 --- a/apps/web-user/src/app/mypage/order/page.tsx +++ b/apps/web-user/src/app/mypage/order/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import Header from "@/apps/web-user/common/components/headers/Header"; import { Tabs } from "@/apps/web-user/common/components/tabs/Tabs"; import { @@ -10,16 +11,26 @@ import { PastOrderList, usePastOrderCount, } from "@/apps/web-user/features/mypage/order/components/PastOrderList"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; +import type { ReservationTabName } from "@/apps/web-user/common/types/analytics.type"; export default function MyOrdersPage() { const upcomingCount = useUpcomingOrderCount(); const pastCount = usePastOrderCount(); + // "내 예약" 메뉴 진입 - 전체 예약 목록 화면 노출 + useEffect(() => { + trackEvent("view_reservation_list"); + }, []); + return (
{ + trackEvent("engage_reservation_tab", { tab_name: tabId as ReservationTabName }); + }} tabs={[ { id: "upcoming", diff --git a/apps/web-user/src/app/mypage/page.tsx b/apps/web-user/src/app/mypage/page.tsx index 6b59531d..8353bb9c 100644 --- a/apps/web-user/src/app/mypage/page.tsx +++ b/apps/web-user/src/app/mypage/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { Icon } from "@/apps/web-user/common/components/icons"; import { PATHS } from "@/apps/web-user/common/constants/paths.constant"; @@ -16,6 +16,7 @@ import { useUpdateMypageProfile } from "@/apps/web-user/features/mypage/hooks/mu import { Toast } from "@/apps/web-user/common/components/toast/Toast"; import { useLoginSheetStore } from "@/apps/web-user/common/store/login-sheet.store"; import { useScrollRestoration } from "@/apps/web-user/common/hooks/useScrollRestoration"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; function getLoginInfo(user: { googleId: string; @@ -58,6 +59,11 @@ const TERMS_MENU = [ ] as const; export default function MypagePage() { + // 마이페이지 메인 화면 노출 + useEffect(() => { + trackEvent("view_mypage"); + }, []); + const { isAuthenticated } = useAuthStore(); const hasHydrated = useAuthHasHydrated(); // 메뉴 클릭 후 뒤로가기 시 클릭했던 스크롤 위치로 복원 (레이아웃이 정해지는 hydration 이후 활성화) @@ -174,7 +180,7 @@ export default function MypagePage() {

더욱 편리한 이용을 위해

- + + trackEvent("engage_reservation_complete_action", { + reservation_id: orderData.id, + action: "home", + }) + } + >
diff --git a/apps/web-user/src/app/saved/page.tsx b/apps/web-user/src/app/saved/page.tsx index f197c9d1..3109831e 100644 --- a/apps/web-user/src/app/saved/page.tsx +++ b/apps/web-user/src/app/saved/page.tsx @@ -22,7 +22,7 @@ export default function SavedPage() { {!hasHydrated ? null : isAuthenticated ? ( ) : ( - + openLoginSheet("save_menu")} /> )} diff --git a/apps/web-user/src/app/search/page.tsx b/apps/web-user/src/app/search/page.tsx index d09a9c34..e6910402 100644 --- a/apps/web-user/src/app/search/page.tsx +++ b/apps/web-user/src/app/search/page.tsx @@ -12,6 +12,7 @@ import { import type { StoreListFilter } from "@/apps/web-user/features/store/types/store.type"; import { hasActiveFilter } from "@/apps/web-user/features/store/components/map/MapStoreListFilter"; import { releaseSoftKeyboard } from "@/apps/web-user/common/utils/soft-keyboard.util"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; const RECENT_SEARCHES_KEY = "recentSearches"; const MAX_RECENT = 10; @@ -42,6 +43,20 @@ export default function SearchPage() { const [isFilterOpen, setIsFilterOpen] = useState(false); const [listFilter, setListFilter] = useState({}); const [sortBy, setSortBy] = useState(DEFAULT_SORT_BY); + const [resultCount, setResultCount] = useState(undefined); + // 필터 적용 직후 다음 result_count 갱신을 success_filter_apply로 처리하기 위한 플래그 + const pendingFilterApplyRef = useRef(false); + // view_search_result 중복 발화 방지 (탭+검색어 조합이 바뀔 때만 재발화) + const lastViewedResultKeyRef = useRef(null); + // resultCount 이펙트에서 최신값을 읽기 위한 ref (탭 전환 등으로 인한 조기 발화 방지 목적으로 deps에서는 제외) + const activeTabRef = useRef(activeTab); + activeTabRef.current = activeTab; + const submittedTermRef = useRef(submittedTerm); + submittedTermRef.current = submittedTerm; + const sortByRef = useRef(sortBy); + sortByRef.current = sortBy; + const listFilterRef = useRef(listFilter); + listFilterRef.current = listFilter; useEffect(() => { const q = searchParams.get("q"); @@ -57,8 +72,39 @@ export default function SearchPage() { releaseSoftKeyboard(); }, []); + // 검색 화면(입력 전) 노출 + useEffect(() => { + trackEvent("view_search"); + }, []); + + // 필터 적용 후 또는 검색 결과가 바뀔 때 result_count가 갱신되면 해당 이벤트를 전송 + // (submittedTerm/activeTab/sortBy/listFilter는 ref로만 읽어 탭 전환 시 이전 카운트로 조기 발화되지 않게 함) + useEffect(() => { + const submitted = submittedTermRef.current; + if (resultCount == null || !submitted.trim()) return; + if (pendingFilterApplyRef.current) { + pendingFilterApplyRef.current = false; + const filter = listFilterRef.current; + trackEvent("success_filter_apply", { + sort_type: sortByRef.current, + size_filter: filter.sizes?.join(",") || undefined, + price_min: filter.minPrice, + price_max: filter.maxPrice, + type_filter: filter.productCategoryTypes?.join(",") || undefined, + result_count: resultCount, + }); + lastViewedResultKeyRef.current = `${activeTabRef.current}:${submitted}`; + return; + } + const key = `${activeTabRef.current}:${submitted}`; + if (lastViewedResultKeyRef.current === key) return; + lastViewedResultKeyRef.current = key; + trackEvent("view_search_result", { keyword: submitted, result_count: resultCount }); + }, [resultCount]); + const handleSearch = (term: string) => { if (!term.trim()) return; + trackEvent("request_search", { keyword: term.trim() }); saveRecentSearch(term.trim()); setSearchTerm(term.trim()); setSubmittedTerm(term.trim()); @@ -103,7 +149,10 @@ export default function SearchPage() {
@@ -156,7 +211,10 @@ export default function SearchPage() { isOpen={isFilterOpen} onClose={() => setIsFilterOpen(false)} filter={listFilter} - onFilterChange={setListFilter} + onFilterChange={(f) => { + pendingFilterApplyRef.current = true; + setListFilter(f); + }} sortBy={sortBy} onSortByChange={setSortBy} /> diff --git a/apps/web-user/src/app/store/[storeId]/page.tsx b/apps/web-user/src/app/store/[storeId]/page.tsx index cf40c878..9fe16fc0 100644 --- a/apps/web-user/src/app/store/[storeId]/page.tsx +++ b/apps/web-user/src/app/store/[storeId]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { use } from "react"; +import { use, useEffect } from "react"; import { useStoreDetail } from "@/apps/web-user/features/store/hooks/queries/useStoreDetail"; import { useProductList } from "@/apps/web-user/features/product/hooks/queries/useProductList"; import { useStoreFeeds } from "@/apps/web-user/features/feed/hooks/queries/useStoreFeeds"; @@ -10,6 +10,7 @@ import { StoreDetailReviewSection } from "@/apps/web-user/features/store/compone import { StoreDetailFeedSection } from "@/apps/web-user/features/store/components/sections/StoreDetailFeedSection"; import { Tabs } from "@/apps/web-user/common/components/tabs/Tabs"; import { StoreDetailSkeleton } from "@/apps/web-user/common/components/skeleton/StoreDetailSkeleton"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; interface StoreDetailPageProps { params: Promise<{ storeId: string }>; @@ -26,6 +27,13 @@ export default function StoreDetailPage({ params }: StoreDetailPageProps) { const productCount = productData?.pages[0]?.meta.totalItems ?? 0; const feedCount = feedData?.meta.totalItems ?? 0; + // 스토어 상세화면 노출 + useEffect(() => { + if (data) { + trackEvent("view_store_detail", { store_id: storeId }); + } + }, [data, storeId]); + if (isLoading) { return ; } diff --git a/apps/web-user/src/common/components/bottom-sheets/EasyPaymentBottomSheet.tsx b/apps/web-user/src/common/components/bottom-sheets/EasyPaymentBottomSheet.tsx index d809f9fc..c1db4659 100644 --- a/apps/web-user/src/common/components/bottom-sheets/EasyPaymentBottomSheet.tsx +++ b/apps/web-user/src/common/components/bottom-sheets/EasyPaymentBottomSheet.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { BottomSheet } from "@/apps/web-user/common/components/bottom-sheets/BottomSheet"; import { Modal } from "@/apps/web-user/common/components/modals/Modal"; import { BottomSheetOptionList } from "./BottomSheetOptionList"; import { APP_ONLY_MODAL } from "@/apps/web-user/common/constants/messages.constant"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; function isMobileDevice(): boolean { return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); @@ -13,6 +14,8 @@ function isMobileDevice(): boolean { interface EasyPaymentBottomSheetProps { isOpen: boolean; onClose: () => void; + /** 입금하려는 예약 고유 식별자 (애널리틱스용) */ + reservationId: string; bankAccountNumber?: string | null; bankName?: string | null; amount: number; @@ -21,12 +24,20 @@ interface EasyPaymentBottomSheetProps { export function EasyPaymentBottomSheet({ isOpen, onClose, + reservationId, bankAccountNumber, bankName, amount, }: EasyPaymentBottomSheetProps) { const [isModalOpen, setIsModalOpen] = useState(false); + // 간편 입금하기 바텀시트 노출 + useEffect(() => { + if (isOpen) { + trackEvent("view_easy_payment_option", { reservation_id: reservationId }); + } + }, [isOpen, reservationId]); + const handleDeepLink = (url: string) => { if (!isMobileDevice()) { setIsModalOpen(true); @@ -68,15 +79,26 @@ export function EasyPaymentBottomSheet({ { icon: { type: "image", src: "/images/contents/toss.png", alt: "토스" }, label: "토스", - onClick: () => + onClick: () => { + trackEvent("engage_easy_payment_option", { + reservation_id: reservationId, + payment_method: "toss", + }); handleDeepLink( `supertoss://send?bank=${bankName}&accountNo=${bankAccountNumber}&amount=${amount}`, - ), + ); + }, }, { icon: { type: "image", src: "/images/contents/kakao.png", alt: "카카오페이" }, label: "카카오페이", - onClick: () => handleDeepLink("kakaotalk://kakaopay/money/to"), + onClick: () => { + trackEvent("engage_easy_payment_option", { + reservation_id: reservationId, + payment_method: "kakao", + }); + handleDeepLink("kakaotalk://kakaopay/money/to"); + }, }, ]} /> diff --git a/apps/web-user/src/common/components/bottom-sheets/PaymentConfirmBottomSheet.tsx b/apps/web-user/src/common/components/bottom-sheets/PaymentConfirmBottomSheet.tsx index 60282647..c050a229 100644 --- a/apps/web-user/src/common/components/bottom-sheets/PaymentConfirmBottomSheet.tsx +++ b/apps/web-user/src/common/components/bottom-sheets/PaymentConfirmBottomSheet.tsx @@ -4,10 +4,13 @@ import { useEffect, useState } from "react"; import { Icon } from "@/apps/web-user/common/components/icons"; import { useMypageProfile } from "@/apps/web-user/features/mypage/hooks/queries/useMypageProfile"; import { BottomSheet } from "./BottomSheet"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; interface PaymentConfirmBottomSheetProps { isOpen: boolean; onClose: () => void; + /** 입금하려는 예약 고유 식별자 (애널리틱스용) */ + reservationId: string; amount: number; onConfirm: (depositorName: string) => void; } @@ -15,6 +18,7 @@ interface PaymentConfirmBottomSheetProps { export function PaymentConfirmBottomSheet({ isOpen, onClose, + reservationId, amount, onConfirm, }: PaymentConfirmBottomSheetProps) { @@ -28,6 +32,13 @@ export function PaymentConfirmBottomSheet({ } }, [isOpen, profile?.name]); + // "입금정보확인" 바텀시트 노출 + useEffect(() => { + if (isOpen) { + trackEvent("view_payment_info_confirm", { reservation_id: reservationId }); + } + }, [isOpen, reservationId]); + const handleClose = () => { setDepositorName(""); onClose(); diff --git a/apps/web-user/src/common/components/headers/Header.tsx b/apps/web-user/src/common/components/headers/Header.tsx index 4b7ea082..e0c39c62 100644 --- a/apps/web-user/src/common/components/headers/Header.tsx +++ b/apps/web-user/src/common/components/headers/Header.tsx @@ -20,6 +20,7 @@ import { Toast } from "@/apps/web-user/common/components/toast/Toast"; import { RegionSelectSheet } from "@/apps/web-user/common/components/headers/RegionSelectSheet"; import { useAlarmUnreadCount } from "@/apps/web-user/features/alarm/hooks/queries/useAlarmUnreadCount"; import { isProduction } from "@/apps/web-user/common/utils/environment.util"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; const REGION_STORAGE_KEY = "picake:selected-region"; const showQaButton = !isProduction(process.env.NEXT_PUBLIC_NODE_ENV); @@ -228,7 +229,10 @@ export default function Header({ const LocationButton = () => ( - diff --git a/apps/web-user/src/features/order/components/detail/OptionChangeBottomSheet.tsx b/apps/web-user/src/features/order/components/detail/OptionChangeBottomSheet.tsx index 48132fcc..8977def9 100644 --- a/apps/web-user/src/features/order/components/detail/OptionChangeBottomSheet.tsx +++ b/apps/web-user/src/features/order/components/detail/OptionChangeBottomSheet.tsx @@ -12,6 +12,7 @@ import { OrderResponse, } from "@/apps/web-user/features/order/types/order.type"; import { useUpdateReservationOrderItems } from "@/apps/web-user/features/order/hooks/mutations/useUpdateReservationOrderItems"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; interface OptionChangeBottomSheetProps { isOpen: boolean; @@ -134,10 +135,18 @@ export function OptionChangeBottomSheet({ 0, ); + trackEvent("request_change_reservation", { + reservation_id: order.id, + change_type: "product_option", + }); updateItems( { orderId: order.id, items: newItems, totalQuantity, totalPrice }, { onSuccess: () => { + trackEvent("success_change_reservation", { + reservation_id: order.id, + change_type: "product_option", + }); onClose(); onSuccess(); }, diff --git a/apps/web-user/src/features/order/components/detail/PaymentPendingActions.tsx b/apps/web-user/src/features/order/components/detail/PaymentPendingActions.tsx index d0cc2a3b..70c6e07b 100644 --- a/apps/web-user/src/features/order/components/detail/PaymentPendingActions.tsx +++ b/apps/web-user/src/features/order/components/detail/PaymentPendingActions.tsx @@ -12,6 +12,7 @@ import { APP_ONLY_MODAL, PAYMENT_COMPLETE_MODAL, } from "@/apps/web-user/common/constants/messages.constant"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; function isMobileDevice(): boolean { return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); @@ -43,6 +44,7 @@ export function PaymentPendingActions({ order }: PaymentPendingActionsProps) { { src: "/images/contents/kakao.png", alt: "카카오" }, ], onClick: () => { + trackEvent("engage_easy_payment", { reservation_id: order.id }); if (!isMobileDevice()) { setIsAppOnlyModalOpen(true); return; @@ -52,7 +54,10 @@ export function PaymentPendingActions({ order }: PaymentPendingActionsProps) { }, { label: isCompleting ? "처리 중..." : "입금 완료했어요", - onClick: () => setIsPaymentSheetOpen(true), + onClick: () => { + trackEvent("engage_payment_complete", { reservation_id: order.id }); + setIsPaymentSheetOpen(true); + }, }, ]} /> @@ -61,6 +66,7 @@ export function PaymentPendingActions({ order }: PaymentPendingActionsProps) { setIsPaymentSheetOpen(false)} + reservationId={order.id} amount={order.totalPrice} onConfirm={(name) => { setDepositorName(name); @@ -106,6 +112,7 @@ export function PaymentPendingActions({ order }: PaymentPendingActionsProps) { setIsEasyPayOpen(false)} + reservationId={order.id} bankAccountNumber={order.storeBankAccountNumber} bankName={order.storeBankName} amount={order.totalPrice} diff --git a/apps/web-user/src/features/order/components/detail/PickupDateChangeBottomSheet.tsx b/apps/web-user/src/features/order/components/detail/PickupDateChangeBottomSheet.tsx index b8cc227d..9767cbb5 100644 --- a/apps/web-user/src/features/order/components/detail/PickupDateChangeBottomSheet.tsx +++ b/apps/web-user/src/features/order/components/detail/PickupDateChangeBottomSheet.tsx @@ -6,6 +6,7 @@ import { Button } from "@/apps/web-user/common/components/buttons/Button"; import { ReservationCalendarView } from "@/apps/web-user/features/product/components/sections/reservation-bottom-sheet/ReservationCalendarView"; import { useUpdateReservationPickupDate } from "@/apps/web-user/features/order/hooks/mutations/useUpdateReservationPickupDate"; import type { StoreBusinessCalendar } from "@/apps/web-user/features/store/types/store.type"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; interface PickupDateChangeBottomSheetProps { isOpen: boolean; @@ -48,10 +49,18 @@ export function PickupDateChangeBottomSheet({ combined.setHours(tempTime.getHours()); combined.setMinutes(tempTime.getMinutes()); combined.setSeconds(0, 0); + trackEvent("request_change_reservation", { + reservation_id: orderId, + change_type: "pickup_date", + }); mutate( { orderId, pickupDate: combined.toISOString() }, { onSuccess: () => { + trackEvent("success_change_reservation", { + reservation_id: orderId, + change_type: "pickup_date", + }); onClose(); onSuccess(); }, diff --git a/apps/web-user/src/features/order/components/detail/ReservationItemsSection.tsx b/apps/web-user/src/features/order/components/detail/ReservationItemsSection.tsx index 75ff24c9..32dcb77f 100644 --- a/apps/web-user/src/features/order/components/detail/ReservationItemsSection.tsx +++ b/apps/web-user/src/features/order/components/detail/ReservationItemsSection.tsx @@ -7,6 +7,7 @@ import { import { PATHS } from "@/apps/web-user/common/constants/paths.constant"; import { OrderDetailSectionTitle } from "./OrderDetailSectionTitle"; import { ReservationItemCard } from "./ReservationItemCard"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; interface ReservationItemsSectionProps { order: OrderResponse; @@ -35,6 +36,7 @@ export function ReservationItemsSection({ order, onChangeOptions }: ReservationI return ( trackEvent("engage_cancel_reservation", { reservation_id: order.id })} className="text-xs text-gray-500 font-bold underline" > 예약취소 diff --git a/apps/web-user/src/features/order/hooks/mutations/useCancelBeforePayment.ts b/apps/web-user/src/features/order/hooks/mutations/useCancelBeforePayment.ts index 762e8c09..d17b5904 100644 --- a/apps/web-user/src/features/order/hooks/mutations/useCancelBeforePayment.ts +++ b/apps/web-user/src/features/order/hooks/mutations/useCancelBeforePayment.ts @@ -3,6 +3,7 @@ import { orderApi } from "@/apps/web-user/features/order/apis/order.api"; import { orderQueryKeys } from "@/apps/web-user/features/order/constants/orderQueryKeys.constant"; import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; export function useCancelBeforePayment() { const queryClient = useQueryClient(); @@ -11,7 +12,8 @@ export function useCancelBeforePayment() { return useMutation({ mutationFn: ({ orderId, reason }: { orderId: string; reason: string }) => orderApi.cancelBeforePayment(orderId, reason), - onSuccess: () => { + onSuccess: (_data, variables) => { + trackEvent("success_cancel_reservation", { reservation_id: variables.orderId }); queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }); }, onError: (error) => { diff --git a/apps/web-user/src/features/order/hooks/mutations/useCreateOrder.ts b/apps/web-user/src/features/order/hooks/mutations/useCreateOrder.ts index 04b52f02..2a424aa7 100644 --- a/apps/web-user/src/features/order/hooks/mutations/useCreateOrder.ts +++ b/apps/web-user/src/features/order/hooks/mutations/useCreateOrder.ts @@ -4,6 +4,7 @@ import { orderApi } from "@/apps/web-user/features/order/apis/order.api"; import { CreateOrderRequest, OrderResponse } from "@/apps/web-user/features/order/types/order.type"; import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; export function useCreateOrder() { const { showAlert } = useAlertStore(); @@ -12,6 +13,7 @@ export function useCreateOrder() { return useMutation, Error, CreateOrderRequest>({ mutationFn: (data: CreateOrderRequest) => orderApi.createOrder(data), onSuccess: (response) => { + trackEvent("success_reservation", { reservation_id: response.id }); // 주문 ID를 쿼리 파라미터로 전달하여 완료 페이지로 이동 router.push(`/reservation/complete?orderId=${response.id}`); }, diff --git a/apps/web-user/src/features/order/hooks/mutations/usePaymentComplete.ts b/apps/web-user/src/features/order/hooks/mutations/usePaymentComplete.ts index 16844858..c100f5b5 100644 --- a/apps/web-user/src/features/order/hooks/mutations/usePaymentComplete.ts +++ b/apps/web-user/src/features/order/hooks/mutations/usePaymentComplete.ts @@ -3,6 +3,7 @@ import { orderApi } from "@/apps/web-user/features/order/apis/order.api"; import { orderQueryKeys } from "@/apps/web-user/features/order/constants/orderQueryKeys.constant"; import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; export function usePaymentComplete() { const queryClient = useQueryClient(); @@ -11,7 +12,8 @@ export function usePaymentComplete() { return useMutation({ mutationFn: ({ orderId, depositorName }: { orderId: string; depositorName: string }) => orderApi.paymentComplete(orderId, depositorName), - onSuccess: () => { + onSuccess: (_data, variables) => { + trackEvent("success_payment_complete", { reservation_id: variables.orderId }); queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }); }, onError: (error) => { diff --git a/apps/web-user/src/features/order/hooks/mutations/useRequestRefund.ts b/apps/web-user/src/features/order/hooks/mutations/useRequestRefund.ts index 924a18ec..8567f771 100644 --- a/apps/web-user/src/features/order/hooks/mutations/useRequestRefund.ts +++ b/apps/web-user/src/features/order/hooks/mutations/useRequestRefund.ts @@ -3,6 +3,7 @@ import { orderApi } from "@/apps/web-user/features/order/apis/order.api"; import { orderQueryKeys } from "@/apps/web-user/features/order/constants/orderQueryKeys.constant"; import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; export function useRequestRefund() { const queryClient = useQueryClient(); @@ -28,7 +29,9 @@ export function useRequestRefund() { bankAccountNumber, accountHolderName, }), - onSuccess: () => { + onSuccess: (_data, variables) => { + trackEvent("success_refund_info", { reservation_id: variables.orderId }); + trackEvent("success_cancel_reservation", { reservation_id: variables.orderId }); queryClient.invalidateQueries({ queryKey: orderQueryKeys.all }); }, onError: (error) => { diff --git a/apps/web-user/src/features/product/components/cards/CakeListItem.tsx b/apps/web-user/src/features/product/components/cards/CakeListItem.tsx index a659b21d..4db3ec70 100644 --- a/apps/web-user/src/features/product/components/cards/CakeListItem.tsx +++ b/apps/web-user/src/features/product/components/cards/CakeListItem.tsx @@ -30,7 +30,7 @@ export function CakeListItem({ product, onCardClick }: CakeListItemProps) { const handleLikeClick = (e: React.MouseEvent) => { e.stopPropagation(); if (!isAuthenticated) { - openLoginSheet(); + openLoginSheet("like_button"); return; } if (isLikeLoading) return; diff --git a/apps/web-user/src/features/product/components/list/ProductList.tsx b/apps/web-user/src/features/product/components/list/ProductList.tsx index d3053218..7ebf52a6 100644 --- a/apps/web-user/src/features/product/components/list/ProductList.tsx +++ b/apps/web-user/src/features/product/components/list/ProductList.tsx @@ -46,7 +46,7 @@ function ProductItem({ product }: ProductItemProps) { const handleLikeClick = (e: React.MouseEvent) => { e.stopPropagation(); if (!isAuthenticated) { - openLoginSheet(); + openLoginSheet("like_button"); return; } if (isLikeLoading) return; diff --git a/apps/web-user/src/features/product/components/sections/SearchProductListSection.tsx b/apps/web-user/src/features/product/components/sections/SearchProductListSection.tsx index c83236e1..55da922c 100644 --- a/apps/web-user/src/features/product/components/sections/SearchProductListSection.tsx +++ b/apps/web-user/src/features/product/components/sections/SearchProductListSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import { useProductList } from "@/apps/web-user/features/product/hooks/queries/useProductList"; import { @@ -21,6 +21,8 @@ interface SearchProductListSectionProps { maxPrice?: number; productCategoryTypes?: ProductCategoryType[]; sortBy?: MapListSortBy; + /** 검색 결과 개수를 상위(검색 화면)로 보고 — 애널리틱스 이벤트용 */ + onResultCountChange?: (count: number) => void; } function toProductSortBy(sortBy: MapListSortBy): SortBy { @@ -33,6 +35,7 @@ export function SearchProductListSection({ maxPrice, productCategoryTypes, sortBy = "distance", + onResultCountChange, }: SearchProductListSectionProps) { const loadMoreRef = useRef(null); const router = useRouter(); @@ -48,6 +51,11 @@ export function SearchProductListSection({ useInfiniteScroll({ hasNextPage, isFetchingNextPage, fetchNextPage, loadMoreRef }); const products = flattenAndDeduplicateInfiniteData(data); + const totalCount = data?.pages[0]?.meta.totalItems; + + useEffect(() => { + if (totalCount != null) onResultCountChange?.(totalCount); + }, [totalCount, onResultCountChange]); if (isLoading) return <>; @@ -65,7 +73,7 @@ export function SearchProductListSection({ router.push(PATHS.PRODUCT.DETAIL(id))} + onCardClick={(id) => router.push(`${PATHS.PRODUCT.DETAIL(id)}?entry_point=search`)} /> ))} diff --git a/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/ReservationBottomSheet.tsx b/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/ReservationBottomSheet.tsx index 681d312f..8e2aa543 100644 --- a/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/ReservationBottomSheet.tsx +++ b/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/ReservationBottomSheet.tsx @@ -14,6 +14,7 @@ import { ReservationConfirmView } from "./ReservationConfirmView"; import { useCreateOrder } from "@/apps/web-user/features/order/hooks/mutations/useCreateOrder"; import { CreateOrderRequest } from "@/apps/web-user/features/order/types/order.type"; import { useUploadFile } from "@/apps/web-user/features/upload/hooks/mutations/useUploadFile"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; export function ReservationBottomSheet({ isOpen, @@ -108,6 +109,7 @@ export function ReservationBottomSheet({ handleRemoveImage, } = useReservationBottomSheet({ isOpen, + productId, price, productType, cakeSizeOptions, @@ -123,6 +125,8 @@ export function ReservationBottomSheet({ if (isSubmitting) return; setIsSubmitting(true); + trackEvent("request_reservation", { product_count: totalQuantity, product_id: productId }); + try { // 각 OrderItem의 이미지를 업로드하고 URL 받기 const itemsWithImageUrls = await Promise.all( diff --git a/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/useReservationBottomSheet.ts b/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/useReservationBottomSheet.ts index 69979c81..5bdccb55 100644 --- a/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/useReservationBottomSheet.ts +++ b/apps/web-user/src/features/product/components/sections/reservation-bottom-sheet/useReservationBottomSheet.ts @@ -6,9 +6,11 @@ import { ProductType, } from "@/apps/web-user/features/product/types/product.type"; import { useMypageProfile } from "@/apps/web-user/features/mypage/hooks/queries/useMypageProfile"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; interface UseReservationBottomSheetProps { isOpen: boolean; + productId: string; price: number; productType: ProductType; cakeSizeOptions?: CakeSizeOption[]; @@ -26,6 +28,7 @@ interface UseReservationBottomSheetProps { */ export function useReservationBottomSheet({ isOpen, + productId, price, productType, cakeSizeOptions, @@ -37,6 +40,21 @@ export function useReservationBottomSheet({ // ======================================== const [view, setView] = useState("options"); + // 옵션 선택 화면에 머무른 시간 측정용 (request_option_complete의 duration_ms) + const optionsViewEnteredAtRef = useRef(null); + useEffect(() => { + if (view === "options") { + optionsViewEnteredAtRef.current = Date.now(); + } + }, [view]); + + // 주문 확인 화면 view 이벤트 (재진입 포함, 화면에 노출될 때마다 전송) + useEffect(() => { + if (isOpen && view === "confirm") { + trackEvent("view_order_confirm"); + } + }, [isOpen, view]); + // ======================================== // 날짜/시간 선택 상태 // - selectedDate: 최종 선택된 날짜+시간 @@ -245,6 +263,11 @@ export function useReservationBottomSheet({ // 현재 옵션을 주문 목록에 추가하고 확인 뷰로 이동 const handleGoToConfirm = () => { + const durationMs = optionsViewEnteredAtRef.current + ? Date.now() - optionsViewEnteredAtRef.current + : 0; + trackEvent("request_option_complete", { duration_ms: durationMs, product_id: productId }); + const sizePrice = cakeSizeOptions?.find((s) => s.displayName === selectedSize)?.price ?? 0; const flavorPrice = cakeFlavorOptions?.find((f) => f.displayName === selectedFlavor)?.price ?? 0; @@ -289,6 +312,7 @@ export function useReservationBottomSheet({ // 삭제 확인 모달 오픈 const handleDeleteClick = (index: number) => { + trackEvent("engage_delete_product", { product_id: productId }); setDeleteTargetIndex(index); setIsDeleteModalOpen(true); }; @@ -304,6 +328,7 @@ export function useReservationBottomSheet({ // 특정 상품 편집 모드로 전환 const handleEditItem = (index: number) => { + trackEvent("engage_edit_option"); const item = orderItems[index]; setSelectedSize(item.size); setSelectedFlavor(item.flavor); @@ -320,6 +345,7 @@ export function useReservationBottomSheet({ // 새 상품 추가 모드로 전환 const handleAddNewItem = () => { + trackEvent("engage_add_product"); resetCurrentOptions(); setIsAddingFromConfirm(true); setIsEditingFromConfirm(false); diff --git a/apps/web-user/src/features/review/hooks/mutations/useCreateReview.ts b/apps/web-user/src/features/review/hooks/mutations/useCreateReview.ts index 5dec7f86..26b5475d 100644 --- a/apps/web-user/src/features/review/hooks/mutations/useCreateReview.ts +++ b/apps/web-user/src/features/review/hooks/mutations/useCreateReview.ts @@ -3,6 +3,7 @@ import { reviewApi } from "@/apps/web-user/features/review/apis/review.api"; import { CreateReviewRequest } from "@/apps/web-user/features/review/types/review.type"; import { useAlertStore } from "@/apps/web-user/common/store/alert.store"; import getApiMessage from "@/apps/web-user/common/utils/getApiMessage"; +import { trackEvent } from "@/apps/web-user/common/utils/analytics.util"; export function useCreateReview() { const queryClient = useQueryClient(); @@ -10,7 +11,8 @@ export function useCreateReview() { return useMutation({ mutationFn: (data: CreateReviewRequest) => reviewApi.createReview(data), - onSuccess: () => { + onSuccess: (_data, variables) => { + trackEvent("success_review_submit", { reservation_id: variables.orderId }); queryClient.invalidateQueries({ queryKey: ["review", "my"] }); queryClient.invalidateQueries({ queryKey: ["review", "writable"] }); }, diff --git a/apps/web-user/src/features/store/components/map/MapStoreCard.tsx b/apps/web-user/src/features/store/components/map/MapStoreCard.tsx index 5a324972..4c0b9b08 100644 --- a/apps/web-user/src/features/store/components/map/MapStoreCard.tsx +++ b/apps/web-user/src/features/store/components/map/MapStoreCard.tsx @@ -111,7 +111,7 @@ export function MapStoreCardContent({ e.preventDefault(); e.stopPropagation(); if (!isAuthenticated) { - openLoginSheet(); + openLoginSheet("like_button"); return; } if (isLikeLoading) return; @@ -159,7 +159,7 @@ export function MapStoreCardContent({