Skip to content

Commit 26aee06

Browse files
authored
Merge pull request #71 from ArticPenguin/feat/ga4-MP
Feat/ga4 mp
2 parents cdb706c + 4c8cc50 commit 26aee06

21 files changed

Lines changed: 1237 additions & 136 deletions

docs/GA4-Data-Taxonomy.md

Lines changed: 309 additions & 0 deletions
Large diffs are not rendered by default.

src/App.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,28 @@ import { Outlet } from "react-router-dom";
88
import { ErrorBoundary } from "react-error-boundary";
99
import { Toaster } from "./components/ui/sonner";
1010
import { PostedTemplatesProvider } from "./contexts/PostedTemplatesContext";
11-
import { sendPageView } from "./utils/analytics";
11+
import { sendExtensionOpen, sendPageView, sendError } from "./utils/analytics";
1212
import { debugLog } from "@/utils/logger";
1313
import "./App.css";
1414

1515
function App() {
16-
// Google Analytics: Extension 열릴 때 페이지뷰 전송
16+
// GA4: popup mount 시 first_open / session_start / extension_open 자동 전송
1717
useEffect(() => {
1818
debugLog(
1919
"%c여길 열어보시다니...\n이 참에 직접 코드 기여도 해주시는 건 어떤가요?",
2020
"font-family: Nanum Gothic; color: darkgreen; padding: 6px; border-radius: 4px; font-size:14px",
2121
);
2222
debugLog("https://github.com/Turtle-Hwan/LinKU");
23-
sendPageView("LinKU Extension - Popup", "chrome-extension://linku/popup");
23+
sendExtensionOpen("popup_home", "popup");
24+
sendPageView("LinKU Extension - Popup");
2425
}, []);
2526

2627
return (
2728
<ErrorBoundary
29+
onError={(error: unknown) => {
30+
const msg = error instanceof Error ? error.message : String(error);
31+
sendError("react_error_boundary", msg, "popup_home");
32+
}}
2833
fallback={
2934
<div className="w-[500px] h-[600px] flex items-center justify-center p-8">
3035
<div className="text-center space-y-4">

src/components/Editor/EditorHeader/EditorHeader.tsx

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,20 @@ import {
1919
import { getTemplate } from '@/apis/templates';
2020
import { areTemplatesEqual } from '@/utils/templateUtils';
2121
import { debugLog, errorLog } from '@/utils/logger';
22+
import {
23+
sendTemplateSaveSuccess,
24+
sendTemplateSaveFail,
25+
sendTemplateSyncSuccess,
26+
sendTemplateSyncFail,
27+
sendTemplatePublishSuccess,
28+
sendTemplatePublishFail,
29+
} from '@/utils/analytics';
2230

2331
export const EditorHeader = () => {
2432
const { state, dispatch } = useEditorContext();
2533
const { syncToServer } = useTemplateSync();
2634
const { publishTemplate } = useTemplatePublish();
2735
const { loadPostedTemplates } = usePostedTemplates();
28-
2936
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
3037
dispatch({ type: 'UPDATE_TEMPLATE_NAME', payload: e.target.value });
3138
};
@@ -100,24 +107,18 @@ export const EditorHeader = () => {
100107

101108
dispatch({ type: 'SAVE_SUCCESS', payload: savedTemplate });
102109

110+
const origin = savedTemplate.cloned ? 'cloned' : 'owned';
111+
sendTemplateSaveSuccess(savedTemplate.templateId, origin, savedTemplate.items.length);
112+
103113
toast.success('저장 완료', {
104114
description: '템플릿이 로컬에 저장되었습니다.',
105115
});
106116
} catch (error) {
107117
errorLog('[EditorHeader] Save failed:', error);
108-
dispatch({
109-
type: 'SAVE_FAILED',
110-
payload:
111-
error instanceof Error
112-
? error.message
113-
: '저장 중 오류가 발생했습니다.',
114-
});
115-
toast.error('저장 실패', {
116-
description:
117-
error instanceof Error
118-
? error.message
119-
: '저장 중 오류가 발생했습니다.',
120-
});
118+
const errMsg = error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.';
119+
dispatch({ type: 'SAVE_FAILED', payload: errMsg });
120+
sendTemplateSaveFail(state.template.templateId, 'save_error', errMsg);
121+
toast.error('저장 실패', { description: errMsg });
121122
}
122123
};
123124

@@ -145,15 +146,18 @@ export const EditorHeader = () => {
145146

146147
if (result.success && result.data) {
147148
dispatch({ type: 'SYNC_SUCCESS', payload: result.data });
149+
sendTemplateSyncSuccess(
150+
state.template.templateId,
151+
result.data.items?.length ?? state.template.items.length
152+
);
148153
toast.success('동기화 완료', {
149154
description: '템플릿이 서버에 동기화되었습니다.',
150155
});
151156
} else {
152157
const errorMsg = result.error || '동기화에 실패했습니다.';
153158
dispatch({ type: 'SYNC_FAILED', payload: errorMsg });
154-
toast.error('동기화 실패', {
155-
description: errorMsg,
156-
});
159+
sendTemplateSyncFail(state.template.templateId, 'sync_failed', errorMsg);
160+
toast.error('동기화 실패', { description: errorMsg });
157161
}
158162
};
159163

@@ -170,13 +174,14 @@ export const EditorHeader = () => {
170174

171175
if (result.success) {
172176
await loadPostedTemplates();
177+
sendTemplatePublishSuccess(state.template.templateId, currentItems.length);
173178
toast.success('게시 완료', {
174179
description: '템플릿이 공개 갤러리에 게시되었습니다.',
175180
});
176181
} else {
177-
toast.error('게시 실패', {
178-
description: result.error || '게시에 실패했습니다.',
179-
});
182+
const errMsg = result.error || '게시에 실패했습니다.';
183+
sendTemplatePublishFail(state.template.templateId, 'publish_failed', errMsg);
184+
toast.error('게시 실패', { description: errMsg });
180185
}
181186
};
182187

src/components/Editor/ItemPropertiesPanel/ItemPropertiesPanel.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { validateLinkForm } from '@/utils/formValidation';
1616
import { IconGrid } from '@/components/Editor/shared/IconGrid';
1717
import type { TemplateIcon, TemplateItem } from '@/types/api';
1818
import { InputGroup } from '@/components/Editor/shared/InputGroup';
19+
import { sendTemplateItemAdd, sendTemplateItemUpdate, sendTemplateItemDelete } from '@/utils/analytics';
1920

2021
export const ItemPropertiesPanel = () => {
2122
const { state, dispatch } = useEditorContext();
@@ -48,6 +49,7 @@ export const ItemPropertiesPanel = () => {
4849
key={selectedItem.templateItemId}
4950
selectedItem={selectedItem}
5051
isFromStaging={isFromStaging}
52+
templateId={state.template?.templateId}
5153
defaultIcons={state.defaultIcons}
5254
userIcons={state.userIcons}
5355
dispatch={dispatch}
@@ -58,6 +60,7 @@ export const ItemPropertiesPanel = () => {
5860
interface ItemPropertiesPanelFormProps {
5961
selectedItem: TemplateItem;
6062
isFromStaging: boolean;
63+
templateId: number | undefined;
6164
defaultIcons: ReturnType<typeof useEditorContext>['state']['defaultIcons'];
6265
userIcons: ReturnType<typeof useEditorContext>['state']['userIcons'];
6366
dispatch: ReturnType<typeof useEditorContext>['dispatch'];
@@ -66,6 +69,7 @@ interface ItemPropertiesPanelFormProps {
6669
const ItemPropertiesPanelForm = ({
6770
selectedItem,
6871
isFromStaging,
72+
templateId,
6973
defaultIcons,
7074
userIcons,
7175
dispatch,
@@ -138,24 +142,28 @@ const ItemPropertiesPanelForm = ({
138142
},
139143
});
140144

145+
sendTemplateItemUpdate('properties', templateId);
141146
toast.success('변경사항이 저장되었습니다.');
142147
};
143148

144149
const handleDelete = () => {
145150
if (isFromStaging) {
146151
// Permanently delete from staging
147152
dispatch({ type: 'REMOVE_FROM_STAGING', payload: selectedItem.templateItemId });
153+
sendTemplateItemDelete('staging', templateId);
148154
toast.info('아이템이 영구 삭제되었습니다.');
149155
} else {
150156
// Move canvas item to staging
151157
dispatch({ type: 'MOVE_TO_STAGING', payload: selectedItem.templateItemId });
158+
sendTemplateItemDelete('canvas', templateId);
152159
toast.info('아이템이 임시 저장 공간으로 이동되었습니다.');
153160
}
154161
};
155162

156163
const handleMoveToCanvas = () => {
157164
if (!isFromStaging) return;
158165
dispatch({ type: 'MOVE_TO_CANVAS', payload: selectedItem.templateItemId });
166+
sendTemplateItemAdd('button', templateId);
159167
toast.success('아이템이 캔버스에 추가되었습니다.');
160168
};
161169

src/components/EmailVerificationDialog.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* 건국대 이메일 인증 다이얼로그
44
*/
55

6-
import { useState } from 'react';
6+
import { useState, useEffect } from 'react';
77
import { toast } from 'sonner';
88
import { Mail, ArrowLeft, Loader2 } from 'lucide-react';
99
import {
@@ -22,6 +22,7 @@ import {
2222
validateAuthCode,
2323
} from '@/utils/formValidation';
2424
import { errorLog } from '@/utils/logger';
25+
import { sendAuthEmailVerificationStart, sendAuthEmailVerificationSuccess } from '@/utils/analytics';
2526

2627
interface EmailVerificationDialogProps {
2728
open: boolean;
@@ -43,6 +44,11 @@ export function EmailVerificationDialog({
4344
const [authCode, setAuthCode] = useState('');
4445
const [isLoading, setIsLoading] = useState(false);
4546

47+
// 다이얼로그가 열릴 때 인증 시작 이벤트 전송
48+
useEffect(() => {
49+
if (open) sendAuthEmailVerificationStart('settings_dialog');
50+
}, [open]);
51+
4652
// Full email address
4753
const kuMail = emailId ? `${emailId}${EMAIL_DOMAIN}` : '';
4854

@@ -102,6 +108,7 @@ export function EmailVerificationDialog({
102108
toast.success('이메일 인증이 완료되었습니다!');
103109
// Store verified email
104110
await chrome.storage.local.set({ kuMail });
111+
sendAuthEmailVerificationSuccess('konkuk.ac.kr');
105112
// Trigger re-login to get member token
106113
onVerificationComplete();
107114
handleClose();

src/components/Labs/LibrarySeatSection.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@/apis';
1111
import { LibrarySeatRoom } from '@/types/api';
1212
import { loadECampusCredentials } from '@/utils/credentials';
13+
import { sendLabsFeatureUse } from '@/utils/analytics';
1314

1415
const LibrarySeatSection = () => {
1516
const [rooms, setRooms] = useState<LibrarySeatRoom[]>([]);
@@ -75,6 +76,7 @@ const LibrarySeatSection = () => {
7576
}, [fetchSeatRooms]);
7677

7778
const handleOpenRoom = (roomId: number) => {
79+
sendLabsFeatureUse('library_seat', 'success');
7880
openLibraryReservationPage(roomId);
7981
};
8082

src/components/Labs/QRGeneratorSection.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
55
import { Info, Download, Check, Upload, X } from "lucide-react";
66
import QRCode from "qrcode";
77
import { warnLog } from '@/utils/logger';
8+
import { sendLabsFeatureUse } from '@/utils/analytics';
89

910
// LinKU 로고 (public/assets/icon128.png) - 고해상도 사용
1011
const LINKU_LOGO_URL = "/assets/icon128.png";
@@ -120,9 +121,11 @@ const QRGeneratorSection = () => {
120121
const dataUrl = await generateQRWithLogo(activeUrl, logoSrc);
121122
setQrDataUrl(dataUrl);
122123
setError("");
124+
sendLabsFeatureUse('qr_generator', 'success');
123125
} catch {
124126
setError("QR 코드 생성에 실패했습니다");
125127
setQrDataUrl("");
128+
sendLabsFeatureUse('qr_generator', 'fail');
126129
} finally {
127130
setIsGenerating(false);
128131
}

src/components/LabsDialog.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from "react";
12
import {
23
Dialog,
34
DialogContent,
@@ -9,13 +10,18 @@ import { ScrollArea } from "@/components/ui/scroll-area";
910
import ServerClockSection from "./Labs/ServerClockSection";
1011
import QRGeneratorSection from "./Labs/QRGeneratorSection";
1112
import LibrarySeatSection from "./Labs/LibrarySeatSection";
13+
import { sendLabsOpen } from "@/utils/analytics";
1214

1315
interface LabsDialogProps {
1416
open: boolean;
1517
onOpenChange: (open: boolean) => void;
1618
}
1719

1820
const LabsDialog = ({ open, onOpenChange }: LabsDialogProps) => {
21+
useEffect(() => {
22+
if (open) sendLabsOpen();
23+
}, [open]);
24+
1925
return (
2026
<Dialog open={open} onOpenChange={onOpenChange}>
2127
<DialogContent className="max-w-md">

src/components/MainLayout.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Input } from "./ui/input";
66
import { Search, Settings, FlaskConical } from "lucide-react";
77
import SettingsDialog from "./SettingsDialog";
88
import LabsDialog from "./LabsDialog";
9-
import { sendButtonClick, sendGAEvent } from "@/utils/analytics";
9+
import { sendButtonClick, sendSearchSubmit } from "@/utils/analytics";
1010

1111
const MainLayout = () => {
1212
return (
@@ -41,10 +41,7 @@ const Header = () => {
4141
onChange={(e) => setText((e.target as HTMLInputElement).value)}
4242
onKeyDown={(e) => {
4343
if (e.key === "Enter") {
44-
sendGAEvent("search", {
45-
search_term: text,
46-
search_location: "header"
47-
});
44+
sendSearchSubmit(text, "header");
4845
window.open(
4946
`https://search.konkuk.ac.kr/main.do?keyword=${text}`
5047
);
@@ -56,10 +53,7 @@ const Header = () => {
5653
<div className="flex items-center gap-2 shrink-0">
5754
<FlaskConical
5855
className="w-5 h-5 text-gray-600 cursor-pointer"
59-
onClick={() => {
60-
sendButtonClick("labs_icon", "header");
61-
setShowLabs(true);
62-
}}
56+
onClick={() => setShowLabs(true)}
6357
/>
6458
<Settings
6559
className="w-5 h-5 text-gray-600 cursor-pointer"

0 commit comments

Comments
 (0)