@comergehq/studio 0.1.27 → 0.1.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +2 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +784 -415
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +462 -93
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/comments/useAppComments.ts +12 -0
- package/src/studio/ComergeStudio.tsx +7 -1
- package/src/studio/analytics/client.ts +103 -0
- package/src/studio/analytics/events.ts +98 -0
- package/src/studio/analytics/track.ts +237 -0
- package/src/studio/bootstrap/StudioBootstrap.tsx +8 -2
- package/src/studio/bootstrap/useStudioBootstrap.ts +18 -2
- package/src/studio/hooks/useAppStats.ts +14 -2
- package/src/studio/hooks/useBundleManager.ts +17 -1
- package/src/studio/hooks/useMergeRequests.ts +63 -14
- package/src/studio/hooks/useStudioActions.ts +34 -1
- package/src/studio/ui/PreviewPanel.tsx +10 -0
- package/src/studio/ui/preview-panel/usePreviewPanelData.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comergehq/studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "Comerge studio",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"react-native": "*",
|
|
69
69
|
"react-native-safe-area-context": "*",
|
|
70
70
|
"react-native-svg": "*",
|
|
71
|
+
"mixpanel-react-native": "*",
|
|
71
72
|
"react-native-zip-archive": "*",
|
|
72
73
|
"react-native-view-shot": "*"
|
|
73
74
|
},
|
|
@@ -2,6 +2,7 @@ import * as React from 'react';
|
|
|
2
2
|
|
|
3
3
|
import { appCommentsRepository } from '../../data/comments/repository';
|
|
4
4
|
import type { AppComment } from '../../data/comments/types';
|
|
5
|
+
import { trackSubmitComment } from '../../studio/analytics/track';
|
|
5
6
|
|
|
6
7
|
export type UseAppCommentsResult = {
|
|
7
8
|
comments: AppComment[];
|
|
@@ -58,7 +59,18 @@ export function useAppComments(appId: string | null): UseAppCommentsResult {
|
|
|
58
59
|
try {
|
|
59
60
|
const newComment = await appCommentsRepository.create(appId, { body: trimmed, commentType: 'general' });
|
|
60
61
|
setComments((prev) => sortByCreatedAtAsc([...prev, newComment]));
|
|
62
|
+
await trackSubmitComment({
|
|
63
|
+
appId,
|
|
64
|
+
commentLength: trimmed.length,
|
|
65
|
+
success: true,
|
|
66
|
+
});
|
|
61
67
|
} catch (e) {
|
|
68
|
+
await trackSubmitComment({
|
|
69
|
+
appId,
|
|
70
|
+
commentLength: trimmed.length,
|
|
71
|
+
success: false,
|
|
72
|
+
error: e,
|
|
73
|
+
});
|
|
62
74
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
63
75
|
throw e;
|
|
64
76
|
} finally {
|
|
@@ -24,6 +24,7 @@ export type ComergeStudioProps = {
|
|
|
24
24
|
appId: string;
|
|
25
25
|
clientKey: string;
|
|
26
26
|
appKey?: string;
|
|
27
|
+
analyticsEnabled?: boolean;
|
|
27
28
|
onNavigateHome?: () => void;
|
|
28
29
|
style?: ViewStyle;
|
|
29
30
|
showBubble?: boolean;
|
|
@@ -36,6 +37,7 @@ export function ComergeStudio({
|
|
|
36
37
|
appId,
|
|
37
38
|
clientKey,
|
|
38
39
|
appKey = 'MicroMain',
|
|
40
|
+
analyticsEnabled,
|
|
39
41
|
onNavigateHome,
|
|
40
42
|
style,
|
|
41
43
|
showBubble = true,
|
|
@@ -57,7 +59,11 @@ export function ComergeStudio({
|
|
|
57
59
|
const captureTargetRef = React.useRef<View | null>(null);
|
|
58
60
|
|
|
59
61
|
return (
|
|
60
|
-
<StudioBootstrap
|
|
62
|
+
<StudioBootstrap
|
|
63
|
+
clientKey={clientKey}
|
|
64
|
+
analyticsEnabled={analyticsEnabled}
|
|
65
|
+
fallback={<View style={{ flex: 1 }} />}
|
|
66
|
+
>
|
|
61
67
|
{({ userId }) => (
|
|
62
68
|
<BottomSheetModalProvider>
|
|
63
69
|
<LiquidGlassResetProvider resetTriggers={[appId, activeAppId, runtimeAppId]}>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import { Mixpanel } from 'mixpanel-react-native';
|
|
3
|
+
|
|
4
|
+
import { log } from '../../core/logger';
|
|
5
|
+
import type {
|
|
6
|
+
StudioAnalyticsEventMap,
|
|
7
|
+
StudioAnalyticsEventName,
|
|
8
|
+
StudioAnalyticsEventPayload,
|
|
9
|
+
} from './events';
|
|
10
|
+
|
|
11
|
+
type StudioAnalyticsInitOptions = {
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
token?: string;
|
|
14
|
+
serverUrl?: string;
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let studioMixpanel: Mixpanel | null = null;
|
|
19
|
+
let studioAnalyticsEnabled = false;
|
|
20
|
+
let initPromise: Promise<void> | null = null;
|
|
21
|
+
|
|
22
|
+
export async function initStudioAnalytics(options: StudioAnalyticsInitOptions) {
|
|
23
|
+
if (initPromise) return initPromise;
|
|
24
|
+
|
|
25
|
+
initPromise = (async () => {
|
|
26
|
+
if (!options.enabled) {
|
|
27
|
+
studioAnalyticsEnabled = false;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const token = (options.token ?? '').trim();
|
|
32
|
+
if (!token) {
|
|
33
|
+
studioAnalyticsEnabled = false;
|
|
34
|
+
log.warn('[studio-analytics] disabled: missing Mixpanel token');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const trackAutomaticEvents = false;
|
|
40
|
+
const useNative = false;
|
|
41
|
+
const serverUrl = (options.serverUrl ?? '').trim() || 'https://api.mixpanel.com';
|
|
42
|
+
const superProperties = {
|
|
43
|
+
runtime: 'comerge-studio',
|
|
44
|
+
platform: Platform.OS,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
studioMixpanel = new Mixpanel(token, trackAutomaticEvents, useNative);
|
|
48
|
+
await studioMixpanel.init(false, superProperties, serverUrl);
|
|
49
|
+
studioMixpanel.setLoggingEnabled(Boolean(options.debug));
|
|
50
|
+
studioMixpanel.setFlushBatchSize(50);
|
|
51
|
+
studioAnalyticsEnabled = true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
studioMixpanel = null;
|
|
54
|
+
studioAnalyticsEnabled = false;
|
|
55
|
+
log.warn('[studio-analytics] init failed', error);
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
return initPromise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function isStudioAnalyticsEnabled() {
|
|
63
|
+
return studioAnalyticsEnabled;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function trackStudioEvent<TName extends StudioAnalyticsEventName>(
|
|
67
|
+
eventName: TName,
|
|
68
|
+
properties: StudioAnalyticsEventPayload<TName>
|
|
69
|
+
) {
|
|
70
|
+
if (!studioAnalyticsEnabled || !studioMixpanel) return;
|
|
71
|
+
try {
|
|
72
|
+
await studioMixpanel.track(eventName, properties as StudioAnalyticsEventMap[TName]);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log.warn('[studio-analytics] track failed', { eventName, error });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function flushStudioAnalytics() {
|
|
79
|
+
if (!studioAnalyticsEnabled || !studioMixpanel) return;
|
|
80
|
+
try {
|
|
81
|
+
await studioMixpanel.flush();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
log.warn('[studio-analytics] flush failed', error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function identifyStudioUser(userId: string) {
|
|
88
|
+
if (!studioAnalyticsEnabled || !studioMixpanel || !userId) return;
|
|
89
|
+
try {
|
|
90
|
+
await studioMixpanel.identify(userId);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
log.warn('[studio-analytics] identify failed', error);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function resetStudioAnalytics() {
|
|
97
|
+
if (!studioAnalyticsEnabled || !studioMixpanel) return;
|
|
98
|
+
try {
|
|
99
|
+
await studioMixpanel.reset();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
log.warn('[studio-analytics] reset failed', error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const STUDIO_ANALYTICS_EVENT_VERSION = 1 as const;
|
|
2
|
+
|
|
3
|
+
type ErrorMetadata = {
|
|
4
|
+
error_code?: string;
|
|
5
|
+
error_domain?: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type InteractionSource = 'preview_panel' | 'unknown';
|
|
9
|
+
|
|
10
|
+
export type RemixAppEventProperties = {
|
|
11
|
+
app_id: string;
|
|
12
|
+
source_app_id: string;
|
|
13
|
+
thread_id?: string;
|
|
14
|
+
success: boolean;
|
|
15
|
+
} & ErrorMetadata;
|
|
16
|
+
|
|
17
|
+
export type EditAppEventProperties = {
|
|
18
|
+
app_id: string;
|
|
19
|
+
thread_id: string;
|
|
20
|
+
prompt_length: number;
|
|
21
|
+
success: boolean;
|
|
22
|
+
} & ErrorMetadata;
|
|
23
|
+
|
|
24
|
+
export type ShareAppEventProperties = {
|
|
25
|
+
app_id: string;
|
|
26
|
+
success: boolean;
|
|
27
|
+
} & ErrorMetadata;
|
|
28
|
+
|
|
29
|
+
export type OpenMergeRequestEventProperties = {
|
|
30
|
+
app_id: string;
|
|
31
|
+
merge_request_id?: string;
|
|
32
|
+
success: boolean;
|
|
33
|
+
} & ErrorMetadata;
|
|
34
|
+
|
|
35
|
+
export type ApproveMergeRequestEventProperties = {
|
|
36
|
+
app_id: string;
|
|
37
|
+
merge_request_id: string;
|
|
38
|
+
success: boolean;
|
|
39
|
+
} & ErrorMetadata;
|
|
40
|
+
|
|
41
|
+
export type RejectMergeRequestEventProperties = {
|
|
42
|
+
app_id: string;
|
|
43
|
+
merge_request_id: string;
|
|
44
|
+
success: boolean;
|
|
45
|
+
} & ErrorMetadata;
|
|
46
|
+
|
|
47
|
+
export type TestBundleEventProperties = {
|
|
48
|
+
app_id: string;
|
|
49
|
+
commit_id?: string;
|
|
50
|
+
success: boolean;
|
|
51
|
+
} & ErrorMetadata;
|
|
52
|
+
|
|
53
|
+
export type LikeAppEventProperties = {
|
|
54
|
+
app_id: string;
|
|
55
|
+
source: InteractionSource;
|
|
56
|
+
success: boolean;
|
|
57
|
+
} & ErrorMetadata;
|
|
58
|
+
|
|
59
|
+
export type UnlikeAppEventProperties = {
|
|
60
|
+
app_id: string;
|
|
61
|
+
source: InteractionSource;
|
|
62
|
+
success: boolean;
|
|
63
|
+
} & ErrorMetadata;
|
|
64
|
+
|
|
65
|
+
export type OpenCommentsEventProperties = {
|
|
66
|
+
app_id: string;
|
|
67
|
+
source: InteractionSource;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type SubmitCommentEventProperties = {
|
|
71
|
+
app_id: string;
|
|
72
|
+
comment_type: 'general';
|
|
73
|
+
comment_length: number;
|
|
74
|
+
success: boolean;
|
|
75
|
+
} & ErrorMetadata;
|
|
76
|
+
|
|
77
|
+
export type StudioAnalyticsEventMap = {
|
|
78
|
+
remix_app: RemixAppEventProperties;
|
|
79
|
+
edit_app: EditAppEventProperties;
|
|
80
|
+
share_app: ShareAppEventProperties;
|
|
81
|
+
open_merge_request: OpenMergeRequestEventProperties;
|
|
82
|
+
approve_merge_request: ApproveMergeRequestEventProperties;
|
|
83
|
+
reject_merge_request: RejectMergeRequestEventProperties;
|
|
84
|
+
test_bundle: TestBundleEventProperties;
|
|
85
|
+
like_app: LikeAppEventProperties;
|
|
86
|
+
unlike_app: UnlikeAppEventProperties;
|
|
87
|
+
open_comments: OpenCommentsEventProperties;
|
|
88
|
+
submit_comment: SubmitCommentEventProperties;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type StudioAnalyticsEventName = keyof StudioAnalyticsEventMap;
|
|
92
|
+
|
|
93
|
+
export type StudioAnalyticsBaseProperties = {
|
|
94
|
+
event_version: typeof STUDIO_ANALYTICS_EVENT_VERSION;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export type StudioAnalyticsEventPayload<TName extends StudioAnalyticsEventName> =
|
|
98
|
+
StudioAnalyticsEventMap[TName] & StudioAnalyticsBaseProperties;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { flushStudioAnalytics, trackStudioEvent } from './client';
|
|
2
|
+
import {
|
|
3
|
+
STUDIO_ANALYTICS_EVENT_VERSION,
|
|
4
|
+
type InteractionSource,
|
|
5
|
+
type StudioAnalyticsEventPayload,
|
|
6
|
+
} from './events';
|
|
7
|
+
|
|
8
|
+
function baseProps() {
|
|
9
|
+
return { event_version: STUDIO_ANALYTICS_EVENT_VERSION } as const;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeError(error: unknown): { error_code?: string; error_domain?: string } {
|
|
13
|
+
if (!error) return {};
|
|
14
|
+
|
|
15
|
+
if (typeof error === 'string') {
|
|
16
|
+
return { error_code: error.slice(0, 120), error_domain: 'string' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (error instanceof Error) {
|
|
20
|
+
return {
|
|
21
|
+
error_code: error.message.slice(0, 120),
|
|
22
|
+
error_domain: error.name || 'Error',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof error === 'object') {
|
|
27
|
+
const candidate = error as { code?: string | number; name?: string; message?: string };
|
|
28
|
+
return {
|
|
29
|
+
error_code: String(candidate.code ?? candidate.message ?? 'unknown_error').slice(0, 120),
|
|
30
|
+
error_domain: candidate.name ?? 'object',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { error_code: 'unknown_error', error_domain: typeof error };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function trackMutationEvent<TName extends keyof Omit<{
|
|
38
|
+
remix_app: true;
|
|
39
|
+
edit_app: true;
|
|
40
|
+
share_app: true;
|
|
41
|
+
open_merge_request: true;
|
|
42
|
+
approve_merge_request: true;
|
|
43
|
+
reject_merge_request: true;
|
|
44
|
+
test_bundle: true;
|
|
45
|
+
like_app: true;
|
|
46
|
+
unlike_app: true;
|
|
47
|
+
submit_comment: true;
|
|
48
|
+
}, never>>(
|
|
49
|
+
name: TName,
|
|
50
|
+
payload: StudioAnalyticsEventPayload<TName>
|
|
51
|
+
) {
|
|
52
|
+
await trackStudioEvent(name, payload);
|
|
53
|
+
await flushStudioAnalytics();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let lastOpenCommentsKey: string | null = null;
|
|
57
|
+
let lastOpenCommentsAt = 0;
|
|
58
|
+
|
|
59
|
+
export async function trackRemixApp(params: {
|
|
60
|
+
appId: string;
|
|
61
|
+
sourceAppId: string;
|
|
62
|
+
threadId?: string;
|
|
63
|
+
success: boolean;
|
|
64
|
+
error?: unknown;
|
|
65
|
+
}) {
|
|
66
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
67
|
+
await trackMutationEvent('remix_app', {
|
|
68
|
+
app_id: params.appId,
|
|
69
|
+
source_app_id: params.sourceAppId,
|
|
70
|
+
thread_id: params.threadId,
|
|
71
|
+
success: params.success,
|
|
72
|
+
...errorProps,
|
|
73
|
+
...baseProps(),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function trackEditApp(params: {
|
|
78
|
+
appId: string;
|
|
79
|
+
threadId: string;
|
|
80
|
+
promptLength: number;
|
|
81
|
+
success: boolean;
|
|
82
|
+
error?: unknown;
|
|
83
|
+
}) {
|
|
84
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
85
|
+
await trackMutationEvent('edit_app', {
|
|
86
|
+
app_id: params.appId,
|
|
87
|
+
thread_id: params.threadId,
|
|
88
|
+
prompt_length: params.promptLength,
|
|
89
|
+
success: params.success,
|
|
90
|
+
...errorProps,
|
|
91
|
+
...baseProps(),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function trackShareApp(params: {
|
|
96
|
+
appId: string;
|
|
97
|
+
success: boolean;
|
|
98
|
+
error?: unknown;
|
|
99
|
+
}) {
|
|
100
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
101
|
+
await trackMutationEvent('share_app', {
|
|
102
|
+
app_id: params.appId,
|
|
103
|
+
success: params.success,
|
|
104
|
+
...errorProps,
|
|
105
|
+
...baseProps(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function trackOpenMergeRequest(params: {
|
|
110
|
+
appId: string;
|
|
111
|
+
mergeRequestId?: string;
|
|
112
|
+
success: boolean;
|
|
113
|
+
error?: unknown;
|
|
114
|
+
}) {
|
|
115
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
116
|
+
await trackMutationEvent('open_merge_request', {
|
|
117
|
+
app_id: params.appId,
|
|
118
|
+
merge_request_id: params.mergeRequestId,
|
|
119
|
+
success: params.success,
|
|
120
|
+
...errorProps,
|
|
121
|
+
...baseProps(),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function trackApproveMergeRequest(params: {
|
|
126
|
+
appId: string;
|
|
127
|
+
mergeRequestId: string;
|
|
128
|
+
success: boolean;
|
|
129
|
+
error?: unknown;
|
|
130
|
+
}) {
|
|
131
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
132
|
+
await trackMutationEvent('approve_merge_request', {
|
|
133
|
+
app_id: params.appId,
|
|
134
|
+
merge_request_id: params.mergeRequestId,
|
|
135
|
+
success: params.success,
|
|
136
|
+
...errorProps,
|
|
137
|
+
...baseProps(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function trackRejectMergeRequest(params: {
|
|
142
|
+
appId: string;
|
|
143
|
+
mergeRequestId: string;
|
|
144
|
+
success: boolean;
|
|
145
|
+
error?: unknown;
|
|
146
|
+
}) {
|
|
147
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
148
|
+
await trackMutationEvent('reject_merge_request', {
|
|
149
|
+
app_id: params.appId,
|
|
150
|
+
merge_request_id: params.mergeRequestId,
|
|
151
|
+
success: params.success,
|
|
152
|
+
...errorProps,
|
|
153
|
+
...baseProps(),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function trackTestBundle(params: {
|
|
158
|
+
appId: string;
|
|
159
|
+
commitId?: string;
|
|
160
|
+
success: boolean;
|
|
161
|
+
error?: unknown;
|
|
162
|
+
}) {
|
|
163
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
164
|
+
await trackMutationEvent('test_bundle', {
|
|
165
|
+
app_id: params.appId,
|
|
166
|
+
commit_id: params.commitId,
|
|
167
|
+
success: params.success,
|
|
168
|
+
...errorProps,
|
|
169
|
+
...baseProps(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function trackLikeApp(params: {
|
|
174
|
+
appId: string;
|
|
175
|
+
source?: InteractionSource;
|
|
176
|
+
success: boolean;
|
|
177
|
+
error?: unknown;
|
|
178
|
+
}) {
|
|
179
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
180
|
+
await trackMutationEvent('like_app', {
|
|
181
|
+
app_id: params.appId,
|
|
182
|
+
source: params.source ?? 'unknown',
|
|
183
|
+
success: params.success,
|
|
184
|
+
...errorProps,
|
|
185
|
+
...baseProps(),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function trackUnlikeApp(params: {
|
|
190
|
+
appId: string;
|
|
191
|
+
source?: InteractionSource;
|
|
192
|
+
success: boolean;
|
|
193
|
+
error?: unknown;
|
|
194
|
+
}) {
|
|
195
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
196
|
+
await trackMutationEvent('unlike_app', {
|
|
197
|
+
app_id: params.appId,
|
|
198
|
+
source: params.source ?? 'unknown',
|
|
199
|
+
success: params.success,
|
|
200
|
+
...errorProps,
|
|
201
|
+
...baseProps(),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export async function trackOpenComments(params: {
|
|
206
|
+
appId: string;
|
|
207
|
+
source?: InteractionSource;
|
|
208
|
+
}) {
|
|
209
|
+
const key = `${params.appId}:${params.source ?? 'unknown'}`;
|
|
210
|
+
const now = Date.now();
|
|
211
|
+
if (lastOpenCommentsKey === key && now - lastOpenCommentsAt < 1000) return;
|
|
212
|
+
lastOpenCommentsKey = key;
|
|
213
|
+
lastOpenCommentsAt = now;
|
|
214
|
+
|
|
215
|
+
await trackStudioEvent('open_comments', {
|
|
216
|
+
app_id: params.appId,
|
|
217
|
+
source: params.source ?? 'unknown',
|
|
218
|
+
...baseProps(),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function trackSubmitComment(params: {
|
|
223
|
+
appId: string;
|
|
224
|
+
commentLength: number;
|
|
225
|
+
success: boolean;
|
|
226
|
+
error?: unknown;
|
|
227
|
+
}) {
|
|
228
|
+
const errorProps = params.success ? {} : normalizeError(params.error);
|
|
229
|
+
await trackMutationEvent('submit_comment', {
|
|
230
|
+
app_id: params.appId,
|
|
231
|
+
comment_type: 'general',
|
|
232
|
+
comment_length: params.commentLength,
|
|
233
|
+
success: params.success,
|
|
234
|
+
...errorProps,
|
|
235
|
+
...baseProps(),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
@@ -16,8 +16,14 @@ export type StudioBootstrapProps = UseStudioBootstrapOptions & {
|
|
|
16
16
|
renderError?: (error: Error) => React.ReactNode;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
-
export function StudioBootstrap({
|
|
20
|
-
|
|
19
|
+
export function StudioBootstrap({
|
|
20
|
+
children,
|
|
21
|
+
fallback,
|
|
22
|
+
renderError,
|
|
23
|
+
clientKey,
|
|
24
|
+
analyticsEnabled,
|
|
25
|
+
}: StudioBootstrapProps) {
|
|
26
|
+
const { ready, error, userId } = useStudioBootstrap({ clientKey, analyticsEnabled });
|
|
21
27
|
|
|
22
28
|
if (error) {
|
|
23
29
|
return (
|
|
@@ -3,11 +3,13 @@ import * as React from 'react';
|
|
|
3
3
|
import { setClientKey } from '../../core/services/http/public';
|
|
4
4
|
import { ensureAuthenticatedSession, ensureAnonymousSession } from '../../core/services/supabase/auth';
|
|
5
5
|
import { isSupabaseClientInjected, setSupabaseConfig } from '../../core/services/supabase/client';
|
|
6
|
+
import { identifyStudioUser, initStudioAnalytics, resetStudioAnalytics } from '../analytics/client';
|
|
6
7
|
const SUPABASE_URL = 'https://xtfxwbckjpfmqubnsusu.supabase.co';
|
|
7
8
|
const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inh0Znh3YmNranBmbXF1Ym5zdXN1Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjA2MDEyMzAsImV4cCI6MjA3NjE3NzIzMH0.dzWGAWrK4CvrmHVHzf8w7JlUZohdap0ZPnLZnABMV8s';
|
|
8
9
|
|
|
9
10
|
export type UseStudioBootstrapOptions = {
|
|
10
11
|
clientKey: string;
|
|
12
|
+
analyticsEnabled?: boolean;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export type StudioBootstrapState = {
|
|
@@ -29,11 +31,25 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
|
|
|
29
31
|
(async () => {
|
|
30
32
|
try {
|
|
31
33
|
setClientKey(options.clientKey);
|
|
32
|
-
const
|
|
34
|
+
const hasInjectedSupabase = isSupabaseClientInjected();
|
|
35
|
+
const requireAuth = hasInjectedSupabase;
|
|
36
|
+
const analyticsEnabled = options.analyticsEnabled ?? hasInjectedSupabase;
|
|
37
|
+
|
|
38
|
+
await initStudioAnalytics({
|
|
39
|
+
enabled: analyticsEnabled,
|
|
40
|
+
token: process.env.EXPO_PUBLIC_MIXPANEL_TOKEN,
|
|
41
|
+
serverUrl: process.env.EXPO_PUBLIC_MIXPANEL_SERVER_URL,
|
|
42
|
+
debug: __DEV__,
|
|
43
|
+
});
|
|
44
|
+
|
|
33
45
|
if (!requireAuth) {
|
|
34
46
|
setSupabaseConfig({ url: SUPABASE_URL, anonKey: SUPABASE_ANON_KEY });
|
|
47
|
+
await resetStudioAnalytics();
|
|
35
48
|
}
|
|
36
49
|
const { user } = requireAuth ? await ensureAuthenticatedSession() : await ensureAnonymousSession();
|
|
50
|
+
if (requireAuth) {
|
|
51
|
+
await identifyStudioUser(user.id);
|
|
52
|
+
}
|
|
37
53
|
|
|
38
54
|
if (cancelled) return;
|
|
39
55
|
setState({ ready: true, userId: user.id, error: null });
|
|
@@ -47,7 +63,7 @@ export function useStudioBootstrap(options: UseStudioBootstrapOptions): StudioBo
|
|
|
47
63
|
return () => {
|
|
48
64
|
cancelled = true;
|
|
49
65
|
};
|
|
50
|
-
}, [options.clientKey]);
|
|
66
|
+
}, [options.analyticsEnabled, options.clientKey]);
|
|
51
67
|
|
|
52
68
|
return state;
|
|
53
69
|
}
|
|
@@ -3,6 +3,8 @@ import * as Haptics from 'expo-haptics';
|
|
|
3
3
|
|
|
4
4
|
import type { App } from '../../data/apps/types';
|
|
5
5
|
import { appLikesRepository } from '../../data/likes/repository';
|
|
6
|
+
import type { InteractionSource } from '../analytics/events';
|
|
7
|
+
import { trackLikeApp, trackOpenComments, trackUnlikeApp } from '../analytics/track';
|
|
6
8
|
|
|
7
9
|
export type UseAppStatsParams = {
|
|
8
10
|
appId: string;
|
|
@@ -11,6 +13,7 @@ export type UseAppStatsParams = {
|
|
|
11
13
|
initialForks?: number;
|
|
12
14
|
initialIsLiked?: boolean;
|
|
13
15
|
onOpenComments?: () => void;
|
|
16
|
+
interactionSource?: InteractionSource;
|
|
14
17
|
};
|
|
15
18
|
|
|
16
19
|
export type AppStatsResult = {
|
|
@@ -30,6 +33,7 @@ export function useAppStats({
|
|
|
30
33
|
initialForks = 0,
|
|
31
34
|
initialIsLiked = false,
|
|
32
35
|
onOpenComments,
|
|
36
|
+
interactionSource = 'unknown',
|
|
33
37
|
}: UseAppStatsParams): AppStatsResult {
|
|
34
38
|
const [likeCount, setLikeCount] = React.useState(initialLikes);
|
|
35
39
|
const [commentCount, setCommentCount] = React.useState(initialComments);
|
|
@@ -77,15 +81,22 @@ export function useAppStats({
|
|
|
77
81
|
if (newIsLiked) {
|
|
78
82
|
const res = await appLikesRepository.create(appId, {});
|
|
79
83
|
if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
|
|
84
|
+
await trackLikeApp({ appId, source: interactionSource, success: true });
|
|
80
85
|
} else {
|
|
81
86
|
const res = await appLikesRepository.removeMine(appId);
|
|
82
87
|
if (typeof res.stats?.total === 'number') setLikeCount(Math.max(0, res.stats.total));
|
|
88
|
+
await trackUnlikeApp({ appId, source: interactionSource, success: true });
|
|
83
89
|
}
|
|
84
90
|
} catch (e) {
|
|
85
91
|
setIsLiked(!newIsLiked);
|
|
86
92
|
setLikeCount((prev) => Math.max(0, prev + (newIsLiked ? -1 : 1)));
|
|
93
|
+
if (newIsLiked) {
|
|
94
|
+
await trackLikeApp({ appId, source: interactionSource, success: false, error: e });
|
|
95
|
+
} else {
|
|
96
|
+
await trackUnlikeApp({ appId, source: interactionSource, success: false, error: e });
|
|
97
|
+
}
|
|
87
98
|
}
|
|
88
|
-
}, [appId, isLiked, likeCount]);
|
|
99
|
+
}, [appId, interactionSource, isLiked, likeCount]);
|
|
89
100
|
|
|
90
101
|
const handleOpenComments = React.useCallback(() => {
|
|
91
102
|
if (!appId) return;
|
|
@@ -93,8 +104,9 @@ export function useAppStats({
|
|
|
93
104
|
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
94
105
|
} catch {
|
|
95
106
|
}
|
|
107
|
+
void trackOpenComments({ appId, source: interactionSource });
|
|
96
108
|
onOpenComments?.();
|
|
97
|
-
}, [appId, onOpenComments]);
|
|
109
|
+
}, [appId, interactionSource, onOpenComments]);
|
|
98
110
|
|
|
99
111
|
return { likeCount, commentCount, forkCount, isLiked, setCommentCount, handleLike, handleOpenComments };
|
|
100
112
|
}
|
|
@@ -5,6 +5,7 @@ import { unzip } from 'react-native-zip-archive';
|
|
|
5
5
|
|
|
6
6
|
import type { Platform as BundlePlatform, Bundle, BundleAsset, BundleStatus } from '../../data/apps/bundles/types';
|
|
7
7
|
import { bundlesRepository } from '../../data/apps/bundles/repository';
|
|
8
|
+
import { trackTestBundle } from '../analytics/track';
|
|
8
9
|
|
|
9
10
|
function sleep(ms: number): Promise<void> {
|
|
10
11
|
return new Promise((r) => setTimeout(r, ms));
|
|
@@ -762,7 +763,22 @@ export function useBundleManager({
|
|
|
762
763
|
}, [load]);
|
|
763
764
|
|
|
764
765
|
const loadTest = React.useCallback(async (src: BundleSource) => {
|
|
765
|
-
|
|
766
|
+
try {
|
|
767
|
+
await load(src, 'test');
|
|
768
|
+
await trackTestBundle({
|
|
769
|
+
appId: src.appId,
|
|
770
|
+
commitId: src.commitId ?? undefined,
|
|
771
|
+
success: true,
|
|
772
|
+
});
|
|
773
|
+
} catch (error) {
|
|
774
|
+
await trackTestBundle({
|
|
775
|
+
appId: src.appId,
|
|
776
|
+
commitId: src.commitId ?? undefined,
|
|
777
|
+
success: false,
|
|
778
|
+
error,
|
|
779
|
+
});
|
|
780
|
+
throw error;
|
|
781
|
+
}
|
|
766
782
|
}, [load]);
|
|
767
783
|
|
|
768
784
|
const restoreBase = React.useCallback(async () => {
|