Skip to content

Commit 385614e

Browse files
authored
Merge pull request #13 from Retsomm/dev
新增分析功能以追蹤語言選擇和課程進度,並優化用戶識別邏輯
2 parents 164543b + 204bcac commit 385614e

5 files changed

Lines changed: 159 additions & 22 deletions

File tree

app/LanguageSelection.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { images } from "@/constants/images";
22
import { defaultLanguageId, languages } from "@/data/languages";
3+
import { captureLanguageSelected } from "@/lib/analytics";
34
import { useLanguageStore } from "@/store/UseLanguageStore";
45
import type { SupportedLanguage } from "@/types/learning";
56
import { Ionicons } from "@expo/vector-icons";
67
import { router } from "expo-router";
78
import { useMemo, useState } from "react";
8-
import { usePostHog } from "posthog-react-native";
99
import {
1010
Image,
1111
Pressable,
@@ -21,7 +21,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
2121
export default function LanguageSelectionScreen() {
2222
const insets = useSafeAreaInsets();
2323
const { width } = useWindowDimensions();
24-
const posthog = usePostHog();
2524
const persistedLanguageId = useLanguageStore((state) => state.selectedLanguageId);
2625
const setSelectedLanguage = useLanguageStore(
2726
(state) => state.setSelectedLanguageId,
@@ -65,10 +64,7 @@ export default function LanguageSelectionScreen() {
6564
return;
6665
}
6766

68-
posthog.capture("language_confirmed", { languageId: selectedLanguage.id });
69-
await posthog.flush().catch((flushError) => {
70-
console.error("Failed to flush language confirmation event", flushError);
71-
});
67+
captureLanguageSelected(selectedLanguage);
7268
setSelectedLanguage(selectedLanguage.id);
7369
router.replace("/");
7470
};
@@ -136,10 +132,7 @@ export default function LanguageSelectionScreen() {
136132
key={language.id}
137133
isSelected={language.id === selectedLanguageId}
138134
language={language}
139-
onPress={() => {
140-
posthog.capture("language_selected", { languageId: language.id });
141-
setSelectedLanguageId(language.id);
142-
}}
135+
onPress={() => setSelectedLanguageId(language.id)}
143136
/>
144137
))}
145138
</View>

app/_layout.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import "../global.css";
22

3-
import { ClerkProvider } from "@clerk/expo";
3+
import { ClerkProvider, useAuth } from "@clerk/expo";
44
import { tokenCache } from "@clerk/expo/token-cache";
55
import { useFonts } from "expo-font";
66
import { Stack, useGlobalSearchParams, usePathname } from "expo-router";
77
import * as SplashScreen from "expo-splash-screen";
88
import * as WebBrowser from "expo-web-browser";
99
import { useEffect, useRef } from "react";
1010
import { PostHogProvider } from "posthog-react-native";
11+
import { identifyPostHogUser } from "@/lib/analytics";
1112
import { posthog } from "@/lib/posthog";
13+
import { useLanguageStore } from "@/store/UseLanguageStore";
1214

1315
SplashScreen.preventAutoHideAsync();
1416
WebBrowser.maybeCompleteAuthSession();
@@ -33,6 +35,33 @@ function getSafeSearchParams(params: Record<string, unknown>) {
3335
);
3436
}
3537

38+
function PostHogUserIdentifier() {
39+
const { isLoaded, isSignedIn, userId } = useAuth();
40+
const selectedLanguageId = useLanguageStore((state) => state.selectedLanguageId);
41+
const lastIdentifySignature = useRef<string | null>(null);
42+
43+
useEffect(() => {
44+
if (!isLoaded || !isSignedIn || !userId) {
45+
lastIdentifySignature.current = null;
46+
return;
47+
}
48+
49+
const identifySignature = `${userId}:${selectedLanguageId ?? "none"}`;
50+
51+
if (lastIdentifySignature.current === identifySignature) {
52+
return;
53+
}
54+
55+
lastIdentifySignature.current = identifySignature;
56+
identifyPostHogUser({
57+
preferredLanguageId: selectedLanguageId,
58+
userId,
59+
});
60+
}, [isLoaded, isSignedIn, selectedLanguageId, userId]);
61+
62+
return null;
63+
}
64+
3665
export default function RootLayout() {
3766
const [loaded, error] = useFonts({
3867
"Poppins-Regular": require("./assets/fonts/Poppins-Regular.ttf"),
@@ -91,6 +120,7 @@ export default function RootLayout() {
91120
}}
92121
>
93122
<ClerkProvider publishableKey={clerkPublishableKey} tokenCache={tokenCache}>
123+
<PostHogUserIdentifier />
94124
<Stack screenOptions={{ headerShown: false }} />
95125
</ClerkProvider>
96126
</PostHogProvider>

app/lesson/[lessonId].tsx

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,48 @@
11
import { AudioTeacherSession } from "@/components/audio-teacher-session";
2+
import { languages } from "@/data/languages";
23
import { lessons } from "@/data/lessons";
4+
import { captureLessonAbandoned, captureLessonStarted } from "@/lib/analytics";
35
import { router, useLocalSearchParams } from "expo-router";
6+
import { useEffect, useRef } from "react";
47
import { Pressable, StyleSheet, Text, View } from "react-native";
58
import { useSafeAreaInsets } from "react-native-safe-area-context";
69

710
export default function LessonDetailScreen() {
811
const insets = useSafeAreaInsets();
912
const { lessonId } = useLocalSearchParams<{ lessonId: string }>();
1013
const lesson = lessons.find((item) => item.id === lessonId);
14+
const lessonStartTimeRef = useRef<number | null>(null);
15+
const didCompleteLessonRef = useRef(false);
16+
const lastQuestionIndexRef = useRef(0);
17+
18+
useEffect(() => {
19+
if (!lesson) {
20+
return;
21+
}
22+
23+
const languageName =
24+
languages.find((language) => language.id === lesson.languageId)?.name ??
25+
lesson.languageId;
26+
27+
lessonStartTimeRef.current = Date.now();
28+
didCompleteLessonRef.current = false;
29+
lastQuestionIndexRef.current = 0;
30+
captureLessonStarted(lesson, languageName);
31+
32+
return () => {
33+
const startedAt = lessonStartTimeRef.current;
34+
35+
if (!startedAt || didCompleteLessonRef.current) {
36+
return;
37+
}
38+
39+
captureLessonAbandoned({
40+
lastQuestionIndex: lastQuestionIndexRef.current,
41+
lessonId: lesson.id,
42+
startedAt,
43+
});
44+
};
45+
}, [lesson]);
1146

1247
if (!lesson) {
1348
return (
@@ -36,7 +71,10 @@ export default function LessonDetailScreen() {
3671
activeTabLabel="Learn"
3772
autoStartCall
3873
lesson={lesson}
39-
onCallEnded={() => router.replace("/learn")}
74+
onCallEnded={() => {
75+
didCompleteLessonRef.current = true;
76+
router.replace("/learn");
77+
}}
4078
/>
4179
);
4280
}

components/auth-screen.tsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { images } from "@/constants/images";
2+
import { identifyPostHogUser } from "@/lib/analytics";
3+
import { useLanguageStore } from "@/store/UseLanguageStore";
24
import { useAuth, useSignIn, useSignUp, useSSO } from "@clerk/expo";
35
import {
46
AntDesign,
@@ -121,8 +123,9 @@ export function AuthScreen({ mode }: AuthScreenProps) {
121123
const { signIn, fetchStatus: signInStatus } = useSignIn();
122124
const { signUp, fetchStatus: signUpStatus } = useSignUp();
123125
const { startSSOFlow } = useSSO();
124-
const { userId } = useAuth();
126+
const { userId: authUserId } = useAuth();
125127
const posthog = usePostHog();
128+
const selectedLanguageId = useLanguageStore((state) => state.selectedLanguageId);
126129
const [emailAddress, setEmailAddress] = useState("");
127130
const [password, setPassword] = useState("");
128131
const [authError, setAuthError] = useState<string | null>(null);
@@ -230,10 +233,12 @@ export function AuthScreen({ mode }: AuthScreenProps) {
230233
return;
231234
}
232235

233-
const userId = signUp.createdUserId;
234-
if (userId) {
235-
posthog.identify(userId, {
236-
$set_once: { sign_up_date: new Date().toISOString() },
236+
const newUserId = signUp.createdUserId;
237+
if (newUserId) {
238+
identifyPostHogUser({
239+
isSignUp: true,
240+
preferredLanguageId: selectedLanguageId,
241+
userId: newUserId,
237242
});
238243
}
239244
posthog.capture("sign_up_completed");
@@ -261,9 +266,12 @@ export function AuthScreen({ mode }: AuthScreenProps) {
261266
return;
262267
}
263268

264-
const userId = getStableUserId(signIn);
265-
if (userId) {
266-
posthog.identify(userId);
269+
const signedInUserId = getStableUserId(signIn);
270+
if (signedInUserId) {
271+
identifyPostHogUser({
272+
preferredLanguageId: selectedLanguageId,
273+
userId: signedInUserId,
274+
});
267275
}
268276
posthog.capture("sign_in_completed");
269277
}
@@ -310,8 +318,15 @@ export function AuthScreen({ mode }: AuthScreenProps) {
310318

311319
if (sessionId) {
312320
await setActive?.({ session: sessionId });
313-
if (userId) {
314-
posthog.identify(userId);
321+
const completedUserId =
322+
getStableUserId(ssoSignUp) ?? getStableUserId(ssoSignIn) ?? authUserId;
323+
324+
if (completedUserId) {
325+
identifyPostHogUser({
326+
isSignUp: Boolean(ssoSignUp?.createdUserId),
327+
preferredLanguageId: selectedLanguageId,
328+
userId: completedUserId,
329+
});
315330
}
316331
posthog.capture("social_auth_completed", { strategy, auth_mode: mode });
317332
router.replace("/");

lib/analytics.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { posthog } from "@/lib/posthog";
2+
import type { Lesson, SupportedLanguage } from "@/types/learning";
3+
4+
type IdentifyUserOptions = {
5+
isSignUp?: boolean;
6+
preferredLanguageId: string | null;
7+
userId: string;
8+
};
9+
10+
export function identifyPostHogUser({
11+
isSignUp = false,
12+
preferredLanguageId,
13+
userId,
14+
}: IdentifyUserOptions) {
15+
posthog.identify(userId, {
16+
$set: {
17+
preferred_language: preferredLanguageId,
18+
},
19+
...(isSignUp
20+
? {
21+
$set_once: {
22+
signup_date: new Date().toISOString(),
23+
},
24+
}
25+
: {}),
26+
});
27+
}
28+
29+
export function captureLanguageSelected(language: SupportedLanguage) {
30+
posthog.capture("language_selected", {
31+
language_code: language.id,
32+
language_name: language.name,
33+
});
34+
}
35+
36+
export function captureLessonStarted(lesson: Lesson, languageName: string) {
37+
posthog.capture("lesson_started", {
38+
language: languageName,
39+
lesson_id: lesson.id,
40+
lesson_number: lesson.order,
41+
});
42+
}
43+
44+
export function captureLessonAbandoned({
45+
lastQuestionIndex,
46+
lessonId,
47+
startedAt,
48+
}: {
49+
lastQuestionIndex: number;
50+
lessonId: string;
51+
startedAt: number;
52+
}) {
53+
posthog.capture("lesson_abandoned", {
54+
last_question_index: lastQuestionIndex,
55+
lesson_id: lessonId,
56+
time_into_lesson_seconds: Math.max(
57+
0,
58+
Math.round((Date.now() - startedAt) / 1000),
59+
),
60+
});
61+
}

0 commit comments

Comments
 (0)