diff --git a/.env.example b/.env.example index b03d376..03bd504 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,12 @@ VITE_API_BASE_URL=http://localhost:8080 # MSW Mock 활성화 (true: mock 사용, false 또는 미설정: 실제 API 사용) VITE_USE_MOCK=true +# PostHog 공개 프로젝트 토큰 (개인 API 키 사용 금지) +VITE_POSTHOG_PROJECT_TOKEN= +VITE_POSTHOG_HOST=https://us.i.posthog.com + +# 기본값: 프로덕션에서만 수집. 개발/mock 검증 시 true, 완전 비활성화 시 false +VITE_POSTHOG_ENABLED= + # Google Analytics 4 Measurement ID VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..41646a5 --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +# 공개 수집 전용 토큰. 개인 API 키가 아니며 Vercel 환경 변수로 덮어쓸 수 있습니다. +VITE_POSTHOG_PROJECT_TOKEN=phc_uS3bb4jEAeVMZVPT6aEM4ztfQ6A3wjTrWGkbENgSMSyG +VITE_POSTHOG_HOST=https://us.i.posthog.com diff --git a/README.md b/README.md index 4cc254b..a30ee2f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - 다크 테마 기본 적용 - FSD(Feature-Sliced Design) 아키텍처 기반 구조화 - Vercel Speed Insights로 실사용자 성능 지표 수집 +- PostHog로 페이지 방문과 로그인 사용자별 사용 흐름 수집 --- @@ -204,6 +205,28 @@ VITE_GA_MEASUREMENT_ID=G-JGZ8JFGKPW > `VITE_USE_MOCK=true`에서는 `auth`, `members`, `attendance`, `calendar`, `operations`, `chat`, `drive` 핸들러를 함께 등록합니다. > 이메일 인증이나 실서비스 백엔드에 묶이지 않고 관리자 출퇴근 예외 처리, 지각비 정산, 야간 근무 표시까지 로컬에서 점검할 수 있습니다. +### PostHog + +[이슈 #420](https://github.com/Yanus306/yanus-groupware/issues/420)에서 제품 분석 연동을 관리합니다. +[yANUs 프로젝트 설정](https://us.posthog.com/project/644476/settings/project-details)에서 공개 프로젝트 토큰을 확인합니다. + +```env +VITE_POSTHOG_PROJECT_TOKEN= +VITE_POSTHOG_HOST=https://us.i.posthog.com +``` + +- 토큰이 없으면 수집하지 않습니다. 개인 API 키를 `VITE_` 변수에 넣지 않습니다. +- 기본적으로 프로덕션 빌드에서만 수집하며, `VITE_USE_MOCK=true` 환경에서는 비활성화합니다. +- 로컬에서 실제 수신을 확인할 때만 `VITE_POSTHOG_ENABLED=true`를 지정합니다. `false`는 수집을 완전히 끕니다. +- 페이지 경로가 바뀔 때 `$pageview`를 수집하고, 로그인 시 사용자 ID만 연결합니다. 로그아웃·계정 변경 시 이전 식별을 초기화합니다. +- 허용한 SDK 기술 속성만 전송합니다. URL 쿼리·해시, referrer, 이메일·이름·채팅·문서 내용은 제외합니다. 자동 클릭 수집과 세션 녹화는 꺼져 있습니다. +- `.env.production`에 yANUs 프로젝트의 공개 수집 토큰을 설정하여 배포 빌드에도 적용합니다. 다른 프로젝트를 사용할 때는 Vercel 환경 변수로 덮어씁니다. Vite 변수는 빌드 시 반영되므로 변경 후 새 빌드가 필요합니다. + +```bash +# 로컬 수신 확인 (테스트 이벤트가 실제 프로젝트에 저장됨) +VITE_POSTHOG_ENABLED=true npm run dev +``` + --- ## 테스트 diff --git a/package-lock.json b/package-lock.json index c5e59c2..ae04b23 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@vercel/speed-insights": "^2.0.0", "framer-motion": "^12.35.0", "lucide-react": "^0.575.0", + "posthog-js": "^1.435.8", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", @@ -2658,6 +2659,31 @@ "node": ">=18" } }, + "node_modules/@posthog/browser-common": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.1.tgz", + "integrity": "sha512-Dl8uV2OZAxfHfWyC7K0Y3HtT7ld+WGLbXIwgCgAGziOMGDIa/bepCRrCxy7IxsEzqY1HOrq/VDdcoNN7hBTSQQ==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.55.3", + "@posthog/types": "^1.413.0" + } + }, + "node_modules/@posthog/core": { + "version": "1.55.3", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.3.tgz", + "integrity": "sha512-XnmG9jo7WlxTeN4oCNWH4ESLQHR6ZPZ+mcq+8JxU62k5i1BAe3BSrybnmXHjqKp7e3rUvLK2bOzGZGSlfHvlNA==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.413.0" + } + }, + "node_modules/@posthog/types": { + "version": "1.413.0", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.413.0.tgz", + "integrity": "sha512-tYAVzNojWckuG/wxBB0fwE70sFruQQq4xbhkci7eH2ARU3mMfUTkvpN1HwBMpm0AtbxidLiH0IFvagmdJ8opPw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -4515,6 +4541,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", @@ -4798,6 +4838,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5320,6 +5369,12 @@ } } }, + "node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -8058,6 +8113,54 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/posthog-js": { + "version": "1.435.8", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.435.8.tgz", + "integrity": "sha512-rUrv0kKhD72Wfu++pBlupvrpt1Z60uTAgNUChAH232a05bbRBzV0P4JqbuZEAaaUXumhuB3a8r+iFMzm/2l1Ow==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.9.1", + "@posthog/core": "^1.55.3", + "@posthog/types": "^1.413.0", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^6.2.1", + "web-vitals-soft-navs": "npm:web-vitals@6.2.1" + }, + "peerDependencies": { + "@types/react": ">=16.8.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/posthog-js/node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/preact": { "version": "10.12.1", "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", @@ -8139,6 +8242,12 @@ "node": ">=6" } }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9916,6 +10025,19 @@ "node": ">=18" } }, + "node_modules/web-vitals": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.2.tgz", + "integrity": "sha512-oto5x6dLEgrRqfcWed+pZEUb2q6ikbFmqF54CRDhI/QGbn+qxC49b4C4OkbH+kb9C3a8shpFD3SgR9kRcs80ZQ==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.2.1.tgz", + "integrity": "sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index 1063ac4..808f24c 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@vercel/speed-insights": "^2.0.0", "framer-motion": "^12.35.0", "lucide-react": "^0.575.0", + "posthog-js": "^1.435.8", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", diff --git a/src/app/AnalyticsObserver.tsx b/src/app/AnalyticsObserver.tsx new file mode 100644 index 0000000..f93c051 --- /dev/null +++ b/src/app/AnalyticsObserver.tsx @@ -0,0 +1,21 @@ +import { useEffect, useRef } from 'react' +import { useLocation } from 'react-router-dom' +import { useApp } from '../features/auth/model' +import { capturePageview, initializeAnalytics, syncAnalyticsUser } from '../shared/lib/analytics' + +export function AnalyticsObserver() { + const { pathname } = useLocation() + const { state, isInitializing } = useApp() + const previousPath = useRef(null) + const userId = state.currentUser?.id ?? null + + useEffect(() => { + if (isInitializing || !initializeAnalytics()) return + syncAnalyticsUser(userId) + if (previousPath.current === pathname) return + previousPath.current = pathname + capturePageview() + }, [isInitializing, pathname, userId]) + + return null +} diff --git a/src/app/__tests__/AnalyticsObserver.test.tsx b/src/app/__tests__/AnalyticsObserver.test.tsx new file mode 100644 index 0000000..8fa9c18 --- /dev/null +++ b/src/app/__tests__/AnalyticsObserver.test.tsx @@ -0,0 +1,90 @@ +import { StrictMode } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, useNavigate } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AnalyticsObserver } from '../AnalyticsObserver' + +const analytics = vi.hoisted(() => ({ + initializeAnalytics: vi.fn(() => true), + syncAnalyticsUser: vi.fn(), + capturePageview: vi.fn(), +})) +const auth = vi.hoisted(() => ({ + state: { currentUser: null }, + isInitializing: false, +})) + +vi.mock('../../shared/lib/analytics', () => analytics) +vi.mock('../../features/auth/model', () => ({ useApp: () => auth })) + +function Navigation() { + const navigate = useNavigate() + return <> + + + +} + +function ObserverScenario() { + return + + + + + +} + +describe('AnalyticsObserver', () => { + beforeEach(() => { + vi.clearAllMocks() + auth.isInitializing = false + analytics.initializeAnalytics.mockReturnValue(true) + }) + + it('captures only one initial pageview under StrictMode', () => { + render() + + expect(analytics.capturePageview).toHaveBeenCalledTimes(1) + }) + + it('captures a pageview when the SPA path changes', async () => { + const user = userEvent.setup() + render() + analytics.capturePageview.mockClear() + + await user.click(screen.getByRole('button', { name: 'register' })) + + expect(analytics.capturePageview).toHaveBeenCalledTimes(1) + }) + + it('does not capture another pageview for query and hash changes', async () => { + const user = userEvent.setup() + render() + analytics.capturePageview.mockClear() + + await user.click(screen.getByRole('button', { name: 'query' })) + + expect(analytics.capturePageview).not.toHaveBeenCalled() + }) + + it('waits for session restoration before capturing', async () => { + auth.isInitializing = true + const { rerender } = render() + expect(analytics.capturePageview).not.toHaveBeenCalled() + + auth.isInitializing = false + rerender() + + await waitFor(() => expect(analytics.capturePageview).toHaveBeenCalledTimes(1)) + }) + + it('does not capture when analytics is disabled', () => { + analytics.initializeAnalytics.mockReturnValue(false) + + render() + + expect(analytics.capturePageview).not.toHaveBeenCalled() + expect(analytics.syncAnalyticsUser).not.toHaveBeenCalled() + }) +}) diff --git a/src/app/router.tsx b/src/app/router.tsx index 3ee6f76..c45c2d2 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -18,10 +18,12 @@ import { Members } from '../pages/members' import { Settings } from '../pages/settings' import { Admin } from '../pages/admin' import { TeamManagement } from '../pages/team-management' +import { AnalyticsObserver } from './AnalyticsObserver' export function AppRouter() { return ( + } /> diff --git a/src/entities/message/model/types.ts b/src/entities/message/model/types.ts index d3b1663..ee81dc0 100644 --- a/src/entities/message/model/types.ts +++ b/src/entities/message/model/types.ts @@ -9,8 +9,12 @@ export interface ChatMessage { timestamp: Date } +export type ChannelType = 'GENERAL' | 'TEAM' | 'DIRECT' + export interface Channel { id: string name: string + type?: ChannelType + memberCount?: number lastMessage?: string } diff --git a/src/features/chat/model/ChatProvider.tsx b/src/features/chat/model/ChatProvider.tsx index 6312326..afd65b0 100644 --- a/src/features/chat/model/ChatProvider.tsx +++ b/src/features/chat/model/ChatProvider.tsx @@ -4,9 +4,38 @@ import { useApp } from '../../auth/model/AppProvider' import type { ChatMessage, Channel } from '../../../entities/message/model/types' import { getChannels, getMessages, sendMessage as apiSendMessage } from '../../../shared/api/chatApi' import type { ApiMessage } from '../../../shared/api/chatApi' +import { getCookie, setCookie } from '../../../shared/lib/cookie' export type { ChatMessage, Channel } from '../../../entities/message/model/types' +const MUTED_CHANNELS_COOKIE_KEY = 'chat-muted-channels' +const LAST_READ_COOKIE_KEY = 'chat-last-read' +const LEFT_CHANNELS_COOKIE_KEY = 'chat-left-channels' + +function loadStringList(cookieKey: string): string[] { + try { + const raw = getCookie(cookieKey) + const parsed = raw ? JSON.parse(raw) : [] + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +function loadMutedChannels(): string[] { + return loadStringList(MUTED_CHANNELS_COOKIE_KEY) +} + +function loadLastRead(): Record { + try { + const raw = getCookie(LAST_READ_COOKIE_KEY) + const parsed = raw ? JSON.parse(raw) : {} + return parsed && typeof parsed === 'object' ? (parsed as Record) : {} + } catch { + return {} + } +} + function apiMsgToChatMsg(m: ApiMessage): ChatMessage { return { id: m.id, @@ -27,6 +56,14 @@ type ChatContextValue = { addMessage: (channelId: string, content?: string, files?: { name: string; url: string; type: string }[]) => void getMessagesByChannel: (channelId: string) => ChatMessage[] refreshChannels: () => Promise + isChannelMuted: (channelId: string) => boolean + toggleChannelMute: (channelId: string) => void + getUnreadCount: (channelId: string) => number + getLastReadAt: (channelId: string) => number | undefined + markChannelRead: (channelId: string) => void + visibleChannels: Channel[] + isChannelLeft: (channelId: string) => boolean + leaveChannel: (channelId: string) => void } const ChatContext = createContext(null) @@ -36,9 +73,76 @@ export function ChatProvider({ children }: { children: ReactNode }) { const [channels, setChannels] = useState([]) const [messages, setMessages] = useState([]) const [activeChannelId, setActiveChannelId] = useState('') + const [mutedChannels, setMutedChannels] = useState(() => loadMutedChannels()) + const [lastReadAt, setLastReadAt] = useState>(() => loadLastRead()) + const [leftChannels, setLeftChannels] = useState(() => loadStringList(LEFT_CHANNELS_COOKIE_KEY)) const isDirectChannel = useCallback((channelId: string) => channelId.startsWith('dm-'), []) + useEffect(() => { + try { + setCookie(MUTED_CHANNELS_COOKIE_KEY, JSON.stringify(mutedChannels)) + } catch { + // 저장 실패는 무시 — 알림 설정은 메모리 상태로만 유지된다 + } + }, [mutedChannels]) + + useEffect(() => { + try { + setCookie(LAST_READ_COOKIE_KEY, JSON.stringify(lastReadAt)) + } catch { + // 저장 실패는 무시 — 읽음 상태는 메모리 상태로만 유지된다 + } + }, [lastReadAt]) + + const isChannelMuted = useCallback( + (channelId: string) => mutedChannels.includes(channelId), + [mutedChannels] + ) + + const toggleChannelMute = useCallback((channelId: string) => { + setMutedChannels((prev) => + prev.includes(channelId) ? prev.filter((id) => id !== channelId) : [...prev, channelId] + ) + }, []) + + const getLastReadAt = useCallback( + (channelId: string) => lastReadAt[channelId], + [lastReadAt] + ) + + const markChannelRead = useCallback((channelId: string) => { + setLastReadAt((prev) => ({ ...prev, [channelId]: Date.now() })) + }, []) + + useEffect(() => { + try { + setCookie(LEFT_CHANNELS_COOKIE_KEY, JSON.stringify(leftChannels)) + } catch { + // 저장 실패는 무시 — 나간 채팅방 목록은 메모리 상태로만 유지된다 + } + }, [leftChannels]) + + const isChannelLeft = useCallback( + (channelId: string) => leftChannels.includes(channelId), + [leftChannels] + ) + + const leaveChannel = useCallback( + (channelId: string) => { + setLeftChannels((prev) => (prev.includes(channelId) ? prev : [...prev, channelId])) + // 나간 방이 현재 보고 있던 방이면 남아있는 첫 번째 방으로 전환한다. + setActiveChannelId((prev) => { + if (prev !== channelId) return prev + const next = channels.find((c) => c.id !== channelId && !leftChannels.includes(c.id)) + return next?.id ?? '' + }) + }, + [channels, leftChannels] + ) + + const visibleChannels = channels.filter((c) => !leftChannels.includes(c.id)) + useEffect(() => { if (!state.currentUser?.id) { setChannels([]) @@ -114,6 +218,22 @@ export function ChatProvider({ children }: { children: ReactNode }) { [messages] ) + const getUnreadCount = useCallback( + (channelId: string) => { + const currentUserId = state.currentUser?.id ?? '' + const incoming = messages.filter((m) => m.channelId === channelId && m.userId !== currentUserId) + const readAt = lastReadAt[channelId] + if (readAt === undefined) { + // 한 번도 열어보지 않은 채널: 불러온 메시지가 있으면 그 개수, + // 아직 메시지를 불러오지 않았다면 미리보기(lastMessage) 기준으로 1건 처리 + if (incoming.length > 0) return incoming.length + return channels.find((c) => c.id === channelId)?.lastMessage ? 1 : 0 + } + return incoming.filter((m) => m.timestamp.getTime() > readAt).length + }, + [channels, lastReadAt, messages, state.currentUser?.id] + ) + return ( {children} diff --git a/src/features/chat/model/__tests__/ChatProvider.test.tsx b/src/features/chat/model/__tests__/ChatProvider.test.tsx index 68add7b..9fbb1e8 100644 --- a/src/features/chat/model/__tests__/ChatProvider.test.tsx +++ b/src/features/chat/model/__tests__/ChatProvider.test.tsx @@ -5,11 +5,18 @@ import { setupServer } from 'msw/node' import { chatHandlers } from '../../../../shared/api/mock/handlers/chat' import { AppProvider, useApp } from '../../../auth/model/AppProvider' import { ChatProvider, useChat } from '../ChatProvider' +import { getCookie } from '../../../../shared/lib/cookie' const server = setupServer(...chatHandlers) +function clearCookies() { + document.cookie.split(';').forEach((c) => { + document.cookie = c.replace(/=.*/, '=').trim() + '=; path=/; max-age=0' + }) +} + beforeAll(() => server.listen()) -afterEach(() => { server.resetHandlers(); localStorage.clear() }) +afterEach(() => { server.resetHandlers(); localStorage.clear(); clearCookies() }) afterAll(() => server.close()) function AuthBootstrap({ children }: { children: ReactNode }) { @@ -52,6 +59,7 @@ const wrapper = ({ children }: { children: ReactNode }) => ( describe('ChatProvider', () => { beforeEach(() => { localStorage.clear() + clearCookies() }) describe('초기 상태', () => { @@ -156,6 +164,94 @@ describe('ChatProvider', () => { }) }) + describe('채팅방 알림 설정 (mute)', () => { + it('기본값은 알림 켜짐(muted 아님)이다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.channels.length).toBeGreaterThan(0)) + expect(result.current.isChannelMuted('1')).toBe(false) + }) + + it('toggleChannelMute로 알림을 끄고 켤 수 있다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.channels.length).toBeGreaterThan(0)) + + act(() => result.current.toggleChannelMute('1')) + expect(result.current.isChannelMuted('1')).toBe(true) + + act(() => result.current.toggleChannelMute('1')) + expect(result.current.isChannelMuted('1')).toBe(false) + }) + + it('알림 설정이 쿠키에 저장된다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.channels.length).toBeGreaterThan(0)) + + act(() => result.current.toggleChannelMute('2')) + await waitFor(() => + expect(JSON.parse(getCookie('chat-muted-channels') ?? '[]')).toContain('2') + ) + }) + }) + + describe('안 읽음/읽음 상태', () => { + it('markChannelRead 이후에는 안 읽음 개수가 0이 된다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.messages.length).toBeGreaterThan(0)) + + act(() => result.current.markChannelRead('1')) + expect(result.current.getUnreadCount('1')).toBe(0) + }) + + it('읽음 처리 후 들어온 상대 메시지는 안 읽음으로 집계된다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.messages.length).toBeGreaterThan(0)) + + act(() => result.current.markChannelRead('3')) + act(() => { + result.current.setActiveChannelId('3') + result.current.addMessage('3', '내가 보낸 메시지') + }) + // 본인이 보낸 메시지는 안 읽음에 포함되지 않는다 + expect(result.current.getUnreadCount('3')).toBe(0) + }) + }) + + describe('채팅방 나가기 (leave)', () => { + it('나간 채널은 visibleChannels에서 제외된다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.channels.length).toBeGreaterThan(0)) + + act(() => result.current.leaveChannel('2')) + + expect(result.current.isChannelLeft('2')).toBe(true) + expect(result.current.visibleChannels.some((c) => c.id === '2')).toBe(false) + // 전체 채널 목록에는 그대로 남아 있다 + expect(result.current.channels.some((c) => c.id === '2')).toBe(true) + }) + + it('현재 보고 있던 방을 나가면 남은 방으로 전환된다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.channels.length).toBeGreaterThan(0)) + + act(() => result.current.setActiveChannelId('2')) + expect(result.current.activeChannelId).toBe('2') + + act(() => result.current.leaveChannel('2')) + expect(result.current.activeChannelId).not.toBe('2') + expect(result.current.isChannelLeft(result.current.activeChannelId)).toBe(false) + }) + + it('나간 방 목록이 쿠키에 저장된다', async () => { + const { result } = renderHook(() => useChat(), { wrapper }) + await waitFor(() => expect(result.current.channels.length).toBeGreaterThan(0)) + + act(() => result.current.leaveChannel('3')) + await waitFor(() => + expect(JSON.parse(getCookie('chat-left-channels') ?? '[]')).toContain('3') + ) + }) + }) + describe('useChat 훅', () => { it('ChatProvider 외부에서 useChat 호출 시 에러를 던진다', () => { expect(() => renderHook(() => useChat())).toThrow( diff --git a/src/pages/chat/__tests__/Chat.test.tsx b/src/pages/chat/__tests__/Chat.test.tsx index 7ee5346..3251c9d 100644 --- a/src/pages/chat/__tests__/Chat.test.tsx +++ b/src/pages/chat/__tests__/Chat.test.tsx @@ -22,16 +22,27 @@ vi.mock('../../../features/auth/model', () => ({ })) vi.mock('../../../features/chat/model', () => ({ - useChat: () => ({ - channels: [ - { id: '1', name: 'General', lastMessage: '안녕하세요!' }, - { id: '2', name: 'Design Team', lastMessage: '디자인 피드백 부탁드려요' }, - ], - activeChannelId: '1', - setActiveChannelId: mockSetActiveChannelId, - addMessage: mockAddMessage, - getMessagesByChannel: () => [], - }), + useChat: () => { + const channels = [ + { id: '1', name: 'General', type: 'GENERAL', memberCount: 23, lastMessage: '안녕하세요!' }, + { id: '2', name: 'Design Team', type: 'TEAM', memberCount: 6, lastMessage: '디자인 피드백 부탁드려요' }, + ] + return { + channels, + activeChannelId: '1', + setActiveChannelId: mockSetActiveChannelId, + addMessage: mockAddMessage, + getMessagesByChannel: () => [], + isChannelMuted: () => false, + toggleChannelMute: vi.fn(), + getUnreadCount: () => 0, + getLastReadAt: () => undefined, + markChannelRead: vi.fn(), + visibleChannels: channels, + isChannelLeft: () => false, + leaveChannel: vi.fn(), + } + }, })) vi.mock('../../../shared/api/membersApi', () => ({ diff --git a/src/pages/chat/chat.css b/src/pages/chat/chat.css index d0dfbbd..ab29676 100644 --- a/src/pages/chat/chat.css +++ b/src/pages/chat/chat.css @@ -101,8 +101,19 @@ } .channel-name { - display: block; + display: flex; + align-items: center; + gap: 6px; font-weight: 500; + min-width: 0; +} + +.channel-kind-icon { + flex-shrink: 0; + color: var(--text-secondary); +} + +.channel-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -118,6 +129,75 @@ white-space: nowrap; } +.channel-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.channel-row .channel-name { + flex: 1; + min-width: 0; +} + +.channel-badges { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.channel-muted-icon { + color: var(--text-secondary); + opacity: 0.7; +} + +.channel-item.muted .channel-name, +.channel-item.muted .channel-last { + opacity: 0.6; +} + +.unread-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--accent-purple); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + line-height: 1; +} + +/* 알림이 꺼진 방은 안 읽음 배지를 중립색으로 낮춰 표시 */ +.channel-item.muted .unread-badge { + background: var(--text-secondary); + opacity: 0.7; +} + +.new-message-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 10px 0; + color: var(--accent-purple); + font-size: 0.72rem; + font-weight: 700; +} + +.new-message-divider::before, +.new-message-divider::after { + content: ''; + flex: 1; + height: 1px; + background: color-mix(in srgb, var(--accent-purple) 35%, transparent); +} + .dm-item { display: flex; align-items: center; @@ -263,6 +343,20 @@ color: var(--text-primary); } +.chat-mute-btn { + transition: color 0.2s ease, background 0.2s ease; +} + +.chat-mute-btn.muted { + color: var(--error); + background: color-mix(in srgb, var(--error) 12%, transparent); +} + +.chat-mute-btn.muted:hover { + color: var(--error); + background: color-mix(in srgb, var(--error) 18%, transparent); +} + .chat-messages { flex: 1; overflow-y: auto; @@ -663,6 +757,23 @@ color: var(--text-on-accent); } +.link-modal-actions button.danger { + background: var(--error); + border: none; + color: #fff; +} + +.leave-modal-desc { + font-size: 0.9rem; + color: var(--text-secondary); + line-height: 1.5; + margin: 4px 0 4px; +} + +.leave-modal-desc strong { + color: var(--text-primary); +} + .send-btn { display: flex; align-items: center; @@ -850,3 +961,81 @@ width: 100%; } } + +/* ===== #404 팀 채팅방 구분 / 멤버수 / 방 메뉴 ===== */ +.channel-item.team-room.active { + background: var(--nav-active); +} + +.channel-item.team-room .channel-kind-icon { + color: var(--accent-purple); +} + +.channel-member-count { + display: block; + margin-top: 4px; + font-size: 0.72rem; + color: var(--text-secondary); +} + +/* 상세(헤더)에서 팀 채팅방 구분 배지 */ +.room-type-badge { + display: inline-flex; + align-items: center; + gap: 3px; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + vertical-align: middle; + color: var(--accent-purple); + background: color-mix(in srgb, var(--accent-purple) 14%, transparent); + border: 1px solid color-mix(in srgb, var(--accent-purple) 24%, transparent); +} + +/* 방 메뉴(⋮) 드롭다운 */ +.chat-room-menu { + position: relative; +} + +.chat-room-menu-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 20; + min-width: 168px; + padding: 6px; + border-radius: 12px; + background: var(--bg-card-solid); + border: 1px solid var(--border-color); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + gap: 2px; +} + +.chat-room-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 9px 10px; + border-radius: 8px; + font-size: 0.85rem; + color: var(--text-primary); + background: transparent; + text-align: left; +} + +.chat-room-menu-item:hover { + background: var(--nav-hover); +} + +.chat-room-menu-item.danger { + color: var(--error); +} + +.chat-room-menu-item.danger:hover { + background: color-mix(in srgb, var(--error) 12%, transparent); +} diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index 7c00e69..023d4e8 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -1,6 +1,6 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useMemo, Fragment } from 'react' import ReactMarkdown from 'react-markdown' -import { Search, Send, Paperclip, Smile, Bold, Italic, Strikethrough, Link as LinkIcon, List, ListOrdered, Code, X, ArrowLeft } from 'lucide-react' +import { Search, Send, Paperclip, Smile, Bold, Italic, Strikethrough, Link as LinkIcon, List, ListOrdered, Code, X, ArrowLeft, Bell, BellOff, Users, LogOut, MoreVertical, Hash } from 'lucide-react' import { useApp } from '../../features/auth/model' import { useChat } from '../../features/chat/model' import type { ChatMessage } from '../../features/chat/model' @@ -12,6 +12,8 @@ const CHANNEL_LABELS: Record = { General: '전체 공지', 'Design Team': '디자인팀', 'Dev Team': '개발팀', + 'Marketing Team': '마케팅팀', + 'Product Team': '프로덕트팀', } function formatTime(date: Date): string { const now = new Date() @@ -85,7 +87,20 @@ function MessageItem({ msg, isOwn }: { msg: ChatMessage; isOwn: boolean }) { export function Chat() { const { state } = useApp() - const { channels, activeChannelId, setActiveChannelId, addMessage, getMessagesByChannel } = useChat() + const { + channels, + activeChannelId, + setActiveChannelId, + addMessage, + getMessagesByChannel, + isChannelMuted, + toggleChannelMute, + getUnreadCount, + getLastReadAt, + markChannelRead, + visibleChannels, + leaveChannel, + } = useChat() const [message, setMessage] = useState('') const [attachedFiles, setAttachedFiles] = useState<{ name: string; url: string; type: string }[]>([]) const [showEmojiPicker, setShowEmojiPicker] = useState(false) @@ -104,6 +119,40 @@ export function Chat() { const activeDirectUser = directRooms.find((user) => `dm-${user.id}` === activeChannelId) const isDirectRoom = activeChannelId.startsWith('dm-') const messages = getMessagesByChannel(activeChannelId) + const activeMuted = isChannelMuted(activeChannelId) + const isTeamRoom = activeChannel?.type === 'TEAM' + + const [showRoomMenu, setShowRoomMenu] = useState(false) + const [showLeaveModal, setShowLeaveModal] = useState(false) + const roomMenuRef = useRef(null) + + // 채널 진입 시점의 마지막 읽음 시각을 캡처해 "여기까지 읽음" 구분선 기준으로 사용한다. + const [readDividerAt, setReadDividerAt] = useState(undefined) + + // 채널 전환 시: 진입 시점의 읽음 시각을 캡처한 뒤 곧바로 읽음 처리한다. + // (markChannelRead가 lastReadAt을 갱신하므로 캡처는 activeChannelId 변경 시에만 1회 수행) + useEffect(() => { + if (!activeChannelId) return + setReadDividerAt(getLastReadAt(activeChannelId)) + markChannelRead(activeChannelId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeChannelId]) + + // 채널을 보고 있는 동안 들어온 메시지도 읽음 처리해 떠날 때 안 읽음으로 남지 않게 한다. + useEffect(() => { + if (!activeChannelId) return + markChannelRead(activeChannelId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]) + + // 진입 시점 이후 도착한 첫 수신 메시지 위에 구분선을 표시한다. + const dividerMessageId = useMemo(() => { + const threshold = readDividerAt ?? 0 + const firstUnread = messages.find( + (m) => m.userId !== currentUserId && m.timestamp.getTime() > threshold, + ) + return firstUnread?.id ?? null + }, [messages, readDividerAt, currentUserId]) useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -141,6 +190,27 @@ export function Chat() { return () => document.removeEventListener('click', onOutside) }, [showEmojiPicker]) + useEffect(() => { + if (!showRoomMenu) return + const onOutside = (e: MouseEvent) => { + if (roomMenuRef.current?.contains(e.target as Node)) return + setShowRoomMenu(false) + } + document.addEventListener('click', onOutside) + return () => document.removeEventListener('click', onOutside) + }, [showRoomMenu]) + + // 채널을 전환하면 열려 있던 방 메뉴를 닫는다. + useEffect(() => { + setShowRoomMenu(false) + }, [activeChannelId]) + + const handleLeaveChannel = () => { + leaveChannel(activeChannelId) + setShowLeaveModal(false) + setShowRoomMenu(false) + } + const handleSend = () => { const trimmed = message.trim() if (!trimmed && !attachedFiles.length) return @@ -236,10 +306,12 @@ export function Chat() { }) } - const filteredChannels = channels.filter((channel) => + const filteredChannels = visibleChannels.filter((channel) => channel.name.toLowerCase().includes(roomQuery.toLowerCase()) || (channel.lastMessage ?? '').toLowerCase().includes(roomQuery.toLowerCase()), ) + const generalChannels = filteredChannels.filter((channel) => channel.type !== 'TEAM') + const teamChannels = filteredChannels.filter((channel) => channel.type === 'TEAM') const filteredDirectRooms = directRooms.filter((user) => user.name.toLowerCase().includes(roomQuery.toLowerCase()) || @@ -253,13 +325,45 @@ export function Chat() { } } + const renderChannelItem = (ch: (typeof filteredChannels)[number], team: boolean) => { + const unread = getUnreadCount(ch.id) + const muted = isChannelMuted(ch.id) + const label = CHANNEL_LABELS[ch.name] ?? ch.name + return ( +
openRoom(ch.id)} + > +
+ + {team ? : } + {label} + + + {muted && } + {unread > 0 && ( + + {unread > 99 ? '99+' : unread} + + )} + +
+ {ch.lastMessage} + {team && ch.memberCount != null && ( + 멤버 {ch.memberCount}명 + )} +
+ ) + } + const roomTitle = isDirectRoom ? activeDirectUser?.name ?? '대화방' : `# ${CHANNEL_LABELS[activeChannel?.name ?? ''] ?? activeChannel?.name ?? '디자인팀'}` const roomMeta = isDirectRoom ? `${formatTeamName(activeDirectUser?.team)} · ${activeDirectUser?.online ? '대화 가능' : '현재 자리 비움'}` - : '23명 참여 중' + : `${activeChannel?.memberCount ?? 0}명 참여 중` return (
@@ -276,18 +380,19 @@ export function Chat() { onChange={(e) => setRoomQuery(e.target.value)} />
+ {generalChannels.length > 0 && ( +
+

채널

+ {generalChannels.map((ch) => renderChannelItem(ch, false))} +
+ )}
-

채널

- {filteredChannels.map((ch) => ( -
openRoom(ch.id)} - > - # {CHANNEL_LABELS[ch.name] ?? ch.name} - {ch.lastMessage} -
- ))} +

팀 채팅방

+ {teamChannels.length > 0 + ? teamChannels.map((ch) => renderChannelItem(ch, true)) + : !roomQuery && ( +
참여 중인 팀 채팅방이 없습니다.
+ )}

개인 대화

@@ -321,13 +426,71 @@ export function Chat() { )}
-

{roomTitle}

+

+ {roomTitle} + {isTeamRoom && ( + + 팀 + + )} +

{roomMeta}
- - + + +
+ + {showRoomMenu && ( +
+ + {isTeamRoom && ( + + )} +
+ )} +
@@ -338,7 +501,14 @@ export function Chat() {
) : ( messages.map((msg) => ( - + + {msg.id === dividerMessageId && ( +
+ 여기까지 읽었어요 +
+ )} + +
)) )}
@@ -431,6 +601,22 @@ export function Chat() {
)} + + {showLeaveModal && ( +
setShowLeaveModal(false)}> +
e.stopPropagation()}> +

채팅방을 나가시겠어요?

+

+ # {CHANNEL_LABELS[activeChannel?.name ?? ''] ?? activeChannel?.name} 채팅방에서 나갑니다. + 나가면 목록에서 사라지고 새 메시지 알림을 받지 않습니다. +

+
+ + +
+
+
+ )} ) } diff --git a/src/pages/dashboard/dashboard.css b/src/pages/dashboard/dashboard.css index d736a64..91e236d 100644 --- a/src/pages/dashboard/dashboard.css +++ b/src/pages/dashboard/dashboard.css @@ -64,6 +64,9 @@ .clock-ring-svg { position: absolute; inset: 0; + /* 링 SVG(170px)가 컨테이너(160/144px)보다 크므로 margin:auto로 항상 중앙 정렬해 + 내부 원(.clock-inner)과 동심원이 되도록 한다. inset:0만으로는 좌상단에 붙어 어긋남 */ + margin: auto; } .clock-inner { diff --git a/src/shared/api/__tests__/chatApi.test.ts b/src/shared/api/__tests__/chatApi.test.ts index 0cfc168..07a8630 100644 --- a/src/shared/api/__tests__/chatApi.test.ts +++ b/src/shared/api/__tests__/chatApi.test.ts @@ -12,8 +12,8 @@ afterAll(() => server.close()) describe('chatApi', () => { it('getChannels() 채널 목록을 반환한다', async () => { const channels = await getChannels() - expect(channels).toHaveLength(3) - expect(channels[0]).toMatchObject({ id: '1', name: 'General' }) + expect(channels).toHaveLength(5) + expect(channels[0]).toMatchObject({ id: '1', name: 'General', type: 'GENERAL' }) }) it('getMessages() 채널 메시지 목록을 반환한다', async () => { diff --git a/src/shared/api/chatApi.ts b/src/shared/api/chatApi.ts index b822e32..3709cee 100644 --- a/src/shared/api/chatApi.ts +++ b/src/shared/api/chatApi.ts @@ -3,6 +3,8 @@ import { baseClient } from './baseClient' export interface ApiChannel { id: string name: string + type?: 'GENERAL' | 'TEAM' | 'DIRECT' + memberCount?: number lastMessage?: string } diff --git a/src/shared/api/mock/handlers/chat.ts b/src/shared/api/mock/handlers/chat.ts index 3866e95..7250580 100644 --- a/src/shared/api/mock/handlers/chat.ts +++ b/src/shared/api/mock/handlers/chat.ts @@ -1,9 +1,11 @@ import { http, HttpResponse } from 'msw' const mockChannels = [ - { id: '1', name: 'General', lastMessage: '안녕하세요!' }, - { id: '2', name: 'Design Team', lastMessage: '디자인 피드백 부탁드려요' }, - { id: '3', name: 'Dev Team', lastMessage: 'PR 리뷰 요청드립니다' }, + { id: '1', name: 'General', type: 'GENERAL', memberCount: 23, lastMessage: '안녕하세요!' }, + { id: '2', name: 'Design Team', type: 'TEAM', memberCount: 6, lastMessage: '디자인 피드백 부탁드려요' }, + { id: '3', name: 'Dev Team', type: 'TEAM', memberCount: 8, lastMessage: 'PR 리뷰 요청드립니다' }, + { id: '4', name: 'Marketing Team', type: 'TEAM', memberCount: 5, lastMessage: '캠페인 일정 공유합니다' }, + { id: '5', name: 'Product Team', type: 'TEAM', memberCount: 4, lastMessage: '로드맵 업데이트했어요' }, ] const mockMessages: Record = { diff --git a/src/shared/lib/__tests__/analytics.test.ts b/src/shared/lib/__tests__/analytics.test.ts new file mode 100644 index 0000000..ea2f8b3 --- /dev/null +++ b/src/shared/lib/__tests__/analytics.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PostHogConfig } from 'posthog-js' + +const sdk = vi.hoisted(() => ({ + init: vi.fn<(token: string, config: Partial) => void>(), + identify: vi.fn(), + reset: vi.fn(), + capture: vi.fn(), + get_property: vi.fn(), +})) + +vi.mock('posthog-js', () => ({ default: sdk })) + +describe('PostHog analytics', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.stubEnv('PROD', true) + vi.stubEnv('VITE_USE_MOCK', 'false') + vi.stubEnv('VITE_POSTHOG_PROJECT_TOKEN', 'phc_test') + vi.stubEnv('VITE_POSTHOG_HOST', 'https://us.i.posthog.com') + vi.stubEnv('VITE_POSTHOG_ENABLED', '') + }) + + afterEach(() => vi.unstubAllEnvs()) + + it.each([ + ['VITE_POSTHOG_PROJECT_TOKEN', ''], + ['VITE_USE_MOCK', 'true'], + ['VITE_POSTHOG_ENABLED', 'false'], + ])('does not collect when %s is %s', async (name, value) => { + vi.stubEnv(name, value) + const { initializeAnalytics } = await import('../analytics') + + const enabled = initializeAnalytics() + + expect(enabled).toBe(false) + expect(sdk.init).not.toHaveBeenCalled() + }) + + it('does not collect in development by default', async () => { + vi.stubEnv('PROD', false) + const { initializeAnalytics } = await import('../analytics') + + expect(initializeAnalytics()).toBe(false) + expect(sdk.init).not.toHaveBeenCalled() + }) + + it('initializes only once when called repeatedly', async () => { + const { initializeAnalytics } = await import('../analytics') + + initializeAnalytics() + initializeAnalytics() + + expect(sdk.init).toHaveBeenCalledTimes(1) + }) + + it('allows a deliberate local smoke test when explicitly enabled', async () => { + vi.stubEnv('PROD', false) + vi.stubEnv('VITE_POSTHOG_ENABLED', 'true') + const { initializeAnalytics } = await import('../analytics') + + expect(initializeAnalytics()).toBe(true) + }) + + it('resets a persisted user identity when the app is signed out', async () => { + sdk.get_property.mockReturnValue('previous-user') + const { initializeAnalytics, syncAnalyticsUser } = await import('../analytics') + initializeAnalytics() + + syncAnalyticsUser(null) + + expect(sdk.reset).toHaveBeenCalledTimes(1) + expect(sdk.identify).not.toHaveBeenCalled() + }) + + it('resets identity before identifying a different account', async () => { + sdk.get_property.mockReturnValue('previous-user') + const { initializeAnalytics, syncAnalyticsUser } = await import('../analytics') + initializeAnalytics() + + syncAnalyticsUser('next-user') + + expect(sdk.reset.mock.invocationCallOrder[0]).toBeLessThan(sdk.identify.mock.invocationCallOrder[0]) + expect(sdk.identify).toHaveBeenCalledWith('next-user') + }) + + it('does not repeat identify when the same account is already identified', async () => { + sdk.get_property.mockReturnValue('same-user') + const { initializeAnalytics, syncAnalyticsUser } = await import('../analytics') + initializeAnalytics() + + syncAnalyticsUser('same-user') + + expect(sdk.identify).not.toHaveBeenCalled() + }) + + it('removes private properties and URL secrets before sending', async () => { + window.history.replaceState({}, '', '/verify-email?token=secret#private') + const { initializeAnalytics } = await import('../analytics') + initializeAnalytics() + const beforeSend = sdk.init.mock.calls[0]?.[1].before_send + if (typeof beforeSend !== 'function') throw new Error('Missing before_send callback') + + const event = beforeSend({ + uuid: 'test-event', event: '$pageview', + properties: { + distinct_id: 'test-user', $current_url: window.location.href, + $referrer: 'https://example.com/?token=secret', email: 'private@example.com', + message: 'private message', $set: { name: 'private name' }, + }, + $set: { email: 'private@example.com' }, + $set_once: { $initial_current_url: window.location.href }, + }) + + expect(event?.properties).toEqual({ + distinct_id: 'test-user', $current_url: window.location.origin + '/verify-email', + $pathname: '/verify-email', + }) + expect(event?.$set).toBeUndefined() + expect(event?.$set_once).toBeUndefined() + }) +}) diff --git a/src/shared/lib/analytics.ts b/src/shared/lib/analytics.ts new file mode 100644 index 0000000..9a0f111 --- /dev/null +++ b/src/shared/lib/analytics.ts @@ -0,0 +1,61 @@ +import posthog from 'posthog-js' + +let initialized = false + +const allowedProperties = new Set([ + 'token', 'distinct_id', '$anon_distinct_id', '$device_id', '$user_id', + '$session_id', '$window_id', '$is_identified', '$lib', '$lib_version', + '$insert_id', '$time', '$process_person_profile', '$current_url', '$pathname', + '$host', '$browser', '$browser_version', '$os', '$os_version', '$device_type', + '$screen_height', '$screen_width', '$viewport_height', '$viewport_width', + '$geoip_disable', +]) + +export function initializeAnalytics(): boolean { + if (initialized) return true + + const token = import.meta.env.VITE_POSTHOG_PROJECT_TOKEN?.trim() + const enabled = import.meta.env.VITE_POSTHOG_ENABLED + if (!token || enabled === 'false') return false + if ((!import.meta.env.PROD || import.meta.env.VITE_USE_MOCK === 'true') && enabled !== 'true') return false + + posthog.init(token, { + api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com', + defaults: '2026-05-30', + person_profiles: 'identified_only', + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + disable_session_recording: true, + disable_surveys: true, + capture_heatmaps: false, + capture_performance: false, + advanced_disable_feature_flags: true, + ip: false, + before_send: (event) => { + if (!event || !['$pageview', '$identify'].includes(event.event)) return null + event.properties = Object.fromEntries( + Object.entries(event.properties).filter(([key]) => allowedProperties.has(key)), + ) + event.properties.$current_url = window.location.origin + window.location.pathname + event.properties.$pathname = window.location.pathname + delete event.$set + delete event.$set_once + return event + }, + }) + initialized = true + return true +} + +export function syncAnalyticsUser(userId: string | null): void { + if (!initialized) return + const previousUserId = posthog.get_property('$user_id') + if (previousUserId === userId) return + if (previousUserId) posthog.reset() + if (userId) posthog.identify(userId) +} + +export function capturePageview(): void { + if (initialized) posthog.capture('$pageview') +} diff --git a/src/shared/lib/cookie.ts b/src/shared/lib/cookie.ts new file mode 100644 index 0000000..8c33fa6 --- /dev/null +++ b/src/shared/lib/cookie.ts @@ -0,0 +1,19 @@ +// 클라이언트에 가볍게 보관할 값(채팅 알림 설정, 읽음 상태 등)을 쿠키로 저장/조회하는 유틸. +// 별도 의존성 없이 document.cookie를 사용한다. 쿠키 용량(도메인당 ~4KB)에 유의해 작은 값만 저장한다. + +const DEFAULT_MAX_AGE_DAYS = 365 + +export function getCookie(name: string): string | null { + const prefix = `${encodeURIComponent(name)}=` + const found = document.cookie.split('; ').find((row) => row.startsWith(prefix)) + return found ? decodeURIComponent(found.slice(prefix.length)) : null +} + +export function setCookie(name: string, value: string, maxAgeDays = DEFAULT_MAX_AGE_DAYS): void { + const maxAge = Math.floor(maxAgeDays * 24 * 60 * 60) + document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax` +} + +export function removeCookie(name: string): void { + document.cookie = `${encodeURIComponent(name)}=; path=/; max-age=0; SameSite=Lax` +}