From 44bdb81a60d3a44ba7e379f3c20fe6d8fb284339 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 08:24:16 +0000 Subject: (대표님) 변경사항 20250707 12시 30분 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/layout/providers.tsx | 3 + components/login/login-form.tsx | 23 +--- components/tracking/page-visit-tracker.tsx | 207 +++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 components/tracking/page-visit-tracker.tsx (limited to 'components') diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx index 78f96d61..ca6f5c21 100644 --- a/components/layout/providers.tsx +++ b/components/layout/providers.tsx @@ -10,6 +10,7 @@ import { SWRConfig } from 'swr' import { TooltipProvider } from "@/components/ui/tooltip" import { SessionManager } from "@/components/layout/SessionManager" // ✅ SessionManager 추가 import createEmotionCache from './createEmotionCashe' +import { PageVisitTracker } from "../tracking/page-visit-tracker" const cache = createEmotionCache() @@ -64,7 +65,9 @@ export function ThemeProvider({ {/* ✅ 간소화된 SWR 설정 적용 */} + {children} + {/* ✅ SessionManager 추가 - 모든 프로바이더 내부에 위치 */} diff --git a/components/login/login-form.tsx b/components/login/login-form.tsx index a71fd15e..99708dd6 100644 --- a/components/login/login-form.tsx +++ b/components/login/login-form.tsx @@ -45,7 +45,7 @@ export function LoginForm({ const [showMfaForm, setShowMfaForm] = useState(false); const [mfaToken, setMfaToken] = useState(''); const [tempAuthKey, setTempAuthKey] = useState(''); - const [mfaUserId, setMfaUserId] = useState(''); + const [mfaUserId, setMfaUserId] = useState(null); const [mfaUserEmail, setMfaUserEmail] = useState(''); const [mfaCountdown, setMfaCountdown] = useState(0); @@ -131,7 +131,7 @@ export function LoginForm({ }; // SMS 토큰 전송 (userId 파라미터 추가) - const handleSendSms = async (userIdParam?: string) => { + const handleSendSms = async (userIdParam?: number) => { const targetUserId = userIdParam || mfaUserId; if (!targetUserId || mfaCountdown > 0) return; @@ -379,7 +379,7 @@ export function LoginForm({ setShowMfaForm(false); setMfaToken(''); setTempAuthKey(''); - setMfaUserId(''); + setMfaUserId(null); setMfaUserEmail(''); setMfaCountdown(0); }; @@ -564,23 +564,6 @@ export function LoginForm({ 비밀번호를 잊으셨나요? )} - - {/* 테스트용 MFA 화면 버튼 */} - {process.env.NODE_ENV === 'development' && ( - - )} ) : ( diff --git a/components/tracking/page-visit-tracker.tsx b/components/tracking/page-visit-tracker.tsx new file mode 100644 index 00000000..09dfc03e --- /dev/null +++ b/components/tracking/page-visit-tracker.tsx @@ -0,0 +1,207 @@ +// components/tracking/page-visit-tracker.tsx +"use client" + +import { useEffect, useRef } from 'react' +import { useSession } from 'next-auth/react' +import { usePathname } from 'next/navigation' + +interface PageVisitTrackerProps { + children: React.ReactNode +} + +export function PageVisitTracker({ children }: PageVisitTrackerProps) { + const { data: session } = useSession() + const pathname = usePathname() + const startTimeRef = useRef(Date.now()) + const isTrackingRef = useRef(false) + + // 추적 제외 경로 판단 + const shouldExcludeFromTracking = (path: string): boolean => { + const excludePaths = [ + '/api', + '/_next', + '/favicon.ico', + '/robots.txt', + '/sitemap.xml' + ] + + const excludeExtensions = [ + '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', + '.css', '.js', '.woff', '.woff2', '.ttf', '.eot' + ] + + return excludePaths.some(exclude => path.startsWith(exclude)) || + excludeExtensions.some(ext => path.includes(ext)) + } + + // 디바이스 타입 감지 + const getDeviceType = (): string => { + if (typeof window === 'undefined') return 'unknown' + + const width = window.innerWidth + if (width < 768) return 'mobile' + if (width < 1024) return 'tablet' + return 'desktop' + } + + // 브라우저 정보 추출 + const getBrowserInfo = () => { + if (typeof navigator === 'undefined') return { name: 'unknown', os: 'unknown' } + + const userAgent = navigator.userAgent + let browserName = 'unknown' + let osName = 'unknown' + + // 간단한 브라우저 감지 + if (userAgent.includes('Chrome')) browserName = 'Chrome' + else if (userAgent.includes('Firefox')) browserName = 'Firefox' + else if (userAgent.includes('Safari')) browserName = 'Safari' + else if (userAgent.includes('Edge')) browserName = 'Edge' + + // 간단한 OS 감지 + if (userAgent.includes('Windows')) osName = 'Windows' + else if (userAgent.includes('Mac')) osName = 'macOS' + else if (userAgent.includes('Linux')) osName = 'Linux' + else if (userAgent.includes('Android')) osName = 'Android' + else if (userAgent.includes('iOS')) osName = 'iOS' + + return { name: browserName, os: osName } + } + + // 페이지 제목 추출 + const getPageTitle = (route: string): string => { + if (typeof document !== 'undefined' && document.title) { + return document.title + } + + // 경로 기반 제목 매핑 + const titleMap: Record = { + '/': 'Home', + '/dashboard': 'Dashboard', + '/profile': 'Profile', + '/settings': 'Settings', + } + + // 언어 코드 제거 후 매핑 + const cleanRoute = route.replace(/^\/[a-z]{2}/, '') || '/' + return titleMap[cleanRoute] || cleanRoute + } + + // 페이지 방문 추적 + const trackPageVisit = async (route: string) => { + if (shouldExcludeFromTracking(route) || isTrackingRef.current) { + return + } + + isTrackingRef.current = true + + try { + const browserInfo = getBrowserInfo() + + const trackingData = { + route, + pageTitle: getPageTitle(route), + referrer: document.referrer || null, + deviceType: getDeviceType(), + browserName: browserInfo.name, + osName: browserInfo.os, + screenResolution: `${screen.width}x${screen.height}`, + windowSize: `${window.innerWidth}x${window.innerHeight}`, + language: navigator.language, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + } + + // 백그라운드로 추적 API 호출 (에러 시에도 메인 기능에 영향 없음) + fetch('/api/tracking/page-visit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(trackingData), + // 백그라운드 요청으로 설정 + keepalive: true, + }).catch(error => { + console.error('Page visit tracking failed:', error) + }) + + } catch (error) { + console.error('Client-side tracking error:', error) + } finally { + isTrackingRef.current = false + } + } + + // 체류 시간 기록 + const trackPageDuration = async (route: string, duration: number) => { + if (shouldExcludeFromTracking(route) || duration < 5) { + return // 5초 미만은 기록하지 않음 + } + + try { + const data = JSON.stringify({ + route, + duration, + timestamp: new Date().toISOString(), + }) + + // navigator.sendBeacon 사용 (페이지 이탈 시에도 안전하게 전송) + if (navigator.sendBeacon) { + navigator.sendBeacon('/api/tracking/page-duration', data) + } else { + // sendBeacon 미지원 시 fallback + fetch('/api/tracking/page-duration', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: data, + keepalive: true, + }).catch(() => { + // 페이지 이탈 시 에러는 무시 + }) + } + } catch (error) { + console.error('Duration tracking error:', error) + } + } + + useEffect(() => { + // 페이지 변경 시 추적 + startTimeRef.current = Date.now() + trackPageVisit(pathname) + + // 페이지 이탈 시 체류 시간 기록 + return () => { + const duration = Math.floor((Date.now() - startTimeRef.current) / 1000) + trackPageDuration(pathname, duration) + } + }, [pathname]) + + // 브라우저 닫기/새로고침 시 체류 시간 기록 + useEffect(() => { + const handleBeforeUnload = () => { + const duration = Math.floor((Date.now() - startTimeRef.current) / 1000) + trackPageDuration(pathname, duration) + } + + // visibility change 이벤트로 탭 변경 감지 + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + const duration = Math.floor((Date.now() - startTimeRef.current) / 1000) + trackPageDuration(pathname, duration) + } else { + startTimeRef.current = Date.now() // 탭 복귀 시 시간 리셋 + } + } + + window.addEventListener('beforeunload', handleBeforeUnload) + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [pathname]) + + return <>{children} +} \ No newline at end of file -- cgit v1.2.3