summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/layout/providers.tsx3
-rw-r--r--components/login/login-form.tsx23
-rw-r--r--components/tracking/page-visit-tracker.tsx207
3 files changed, 213 insertions, 20 deletions
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({
<SessionProvider>
{/* ✅ 간소화된 SWR 설정 적용 */}
<SWRConfig value={swrConfig}>
+ <PageVisitTracker>
{children}
+ </PageVisitTracker>
{/* ✅ SessionManager 추가 - 모든 프로바이더 내부에 위치 */}
<SessionManager lng={lng} />
</SWRConfig>
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({
비밀번호를 잊으셨나요?
</Button>
)}
-
- {/* 테스트용 MFA 화면 버튼 */}
- {process.env.NODE_ENV === 'development' && (
- <Button
- type="button"
- variant="link"
- className="text-green-600 hover:text-green-800 text-sm"
- onClick={() => {
- setTempAuthKey('test-temp-key');
- setMfaUserId('test-user');
- setMfaUserEmail('test@example.com');
- setShowMfaForm(true);
- }}
- >
- [개발용] MFA 화면 테스트
- </Button>
- )}
</div>
</>
) : (
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<number>(Date.now())
+ const isTrackingRef = useRef<boolean>(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<string, string> = {
+ '/': '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