feat: product analytic (#5099)
* feat: product analytic * chore: remove comment
This commit is contained in:
parent
484caf04aa
commit
b29e579042
@ -46,6 +46,7 @@
|
||||
"lucide-react": "^0.503.0",
|
||||
"motion": "^12.10.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"posthog-js": "^1.246.0",
|
||||
"react": "^19.0.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@ -11,4 +11,6 @@ export const localStorageKey = {
|
||||
settingMCPSevers: 'setting-mcp-servers',
|
||||
settingLocalApiServer: 'setting-local-api-server',
|
||||
settingHardware: 'setting-hardware',
|
||||
productAnalyticPrompt: 'productAnalyticPrompt',
|
||||
productAnalytic: 'productAnalytic',
|
||||
}
|
||||
|
||||
50
web-app/src/containers/analytics/PromptAnalytic.tsx
Normal file
50
web-app/src/containers/analytics/PromptAnalytic.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||
import { IconFileTextShield } from '@tabler/icons-react'
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
export function PromptAnalytic() {
|
||||
const { setProductAnalyticPrompt, setProductAnalytic } = useAnalytic()
|
||||
|
||||
const handleProductAnalytics = (isAllowed: boolean) => {
|
||||
if (isAllowed) {
|
||||
posthog.opt_in_capturing()
|
||||
setProductAnalytic(true)
|
||||
setProductAnalyticPrompt(false)
|
||||
} else {
|
||||
posthog.opt_out_capturing()
|
||||
setProductAnalytic(false)
|
||||
setProductAnalyticPrompt(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 p-4 shadow-lg bg-main-view w-100 border border-main-view-fg/8 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconFileTextShield className="text-accent" />
|
||||
<h2 className="font-medium text-main-view-fg/80">
|
||||
Help Us Improve Jan
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-main-view-fg/70">
|
||||
We collect anonymous data to understand feature usage. Your chats and
|
||||
personal information are never tracked. You can change this anytime
|
||||
in
|
||||
<span className="font-medium text-main-view-fg">{`Settings > Privacy.`}</span>
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-main-view-fg/80">
|
||||
Would you like to help us to improve Jan?
|
||||
</p>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-main-view-fg/70"
|
||||
onClick={() => handleProductAnalytics(false)}
|
||||
>
|
||||
Deny
|
||||
</Button>
|
||||
<Button onClick={() => handleProductAnalytics(true)}>Allow</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
web-app/src/hooks/useAnalytic.ts
Normal file
76
web-app/src/hooks/useAnalytic.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { localStorageKey } from '@/constants/localStorage'
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
|
||||
export const useAnalytic = () => {
|
||||
const { productAnalyticPrompt, setProductAnalyticPrompt } =
|
||||
useProductAnalyticPrompt()
|
||||
const { productAnalytic, setProductAnalytic } = useProductAnalytic()
|
||||
|
||||
const updateAnalytic = ({
|
||||
productAnalyticPrompt,
|
||||
productAnalytic,
|
||||
}: {
|
||||
productAnalyticPrompt: boolean
|
||||
productAnalytic: boolean
|
||||
}) => {
|
||||
setProductAnalyticPrompt(productAnalyticPrompt)
|
||||
setProductAnalytic(productAnalytic)
|
||||
}
|
||||
|
||||
return {
|
||||
productAnalyticPrompt,
|
||||
setProductAnalyticPrompt,
|
||||
productAnalytic,
|
||||
setProductAnalytic,
|
||||
updateAnalytic,
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductAnalyticPromptState = {
|
||||
productAnalyticPrompt: boolean
|
||||
setProductAnalyticPrompt: (value: boolean) => void
|
||||
}
|
||||
|
||||
export const useProductAnalyticPrompt = create<ProductAnalyticPromptState>()(
|
||||
persist(
|
||||
(set) => {
|
||||
const initialState = {
|
||||
productAnalyticPrompt: true,
|
||||
setProductAnalyticPrompt: async (value: boolean) => {
|
||||
set(() => ({ productAnalyticPrompt: value }))
|
||||
},
|
||||
}
|
||||
|
||||
return initialState
|
||||
},
|
||||
{
|
||||
name: localStorageKey.productAnalyticPrompt,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
export type ProductAnalyticState = {
|
||||
productAnalytic: boolean
|
||||
setProductAnalytic: (value: boolean) => void
|
||||
}
|
||||
|
||||
export const useProductAnalytic = create<ProductAnalyticState>()(
|
||||
persist(
|
||||
(set) => {
|
||||
const initialState = {
|
||||
productAnalytic: false,
|
||||
setProductAnalytic: async (value: boolean) => {
|
||||
set(() => ({ productAnalytic: value }))
|
||||
},
|
||||
}
|
||||
|
||||
return initialState
|
||||
},
|
||||
{
|
||||
name: localStorageKey.productAnalytic,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
65
web-app/src/providers/AnalyticProvider.tsx
Normal file
65
web-app/src/providers/AnalyticProvider.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import posthog from 'posthog-js'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { getAppDistinctId, updateDistinctId } from '@/services/analytic'
|
||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||
|
||||
export function AnalyticProvider() {
|
||||
const { productAnalytic } = useAnalytic()
|
||||
|
||||
useEffect(() => {
|
||||
if (!POSTHOG_KEY || !POSTHOG_HOST) {
|
||||
console.warn(
|
||||
'PostHog not initialized: Missing POSTHOG_KEY or POSTHOG_HOST environment variables'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (productAnalytic) {
|
||||
posthog.init(POSTHOG_KEY, {
|
||||
api_host: POSTHOG_HOST,
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false,
|
||||
disable_session_recording: true,
|
||||
person_profiles: 'always',
|
||||
persistence: 'localStorage',
|
||||
opt_out_capturing_by_default: true,
|
||||
|
||||
sanitize_properties: function (properties) {
|
||||
const denylist = [
|
||||
'$pathname',
|
||||
'$initial_pathname',
|
||||
'$current_url',
|
||||
'$initial_current_url',
|
||||
'$host',
|
||||
'$initial_host',
|
||||
'$initial_person_info',
|
||||
]
|
||||
|
||||
denylist.forEach((key) => {
|
||||
if (properties[key]) {
|
||||
properties[key] = null // Set each denied property to null
|
||||
}
|
||||
})
|
||||
|
||||
return properties
|
||||
},
|
||||
})
|
||||
// Attempt to restore distinct Id from app global settings
|
||||
getAppDistinctId()
|
||||
.then((id) => {
|
||||
if (id) posthog.identify(id)
|
||||
})
|
||||
.finally(() => {
|
||||
posthog.opt_in_capturing()
|
||||
posthog.register({ app_version: VERSION })
|
||||
updateDistinctId(posthog.get_distinct_id())
|
||||
})
|
||||
} else {
|
||||
posthog.opt_out_capturing()
|
||||
}
|
||||
}, [productAnalytic])
|
||||
|
||||
// This component doesn't render anything
|
||||
return null
|
||||
}
|
||||
@ -12,14 +12,20 @@ import { DataProvider } from '@/providers/DataProvider'
|
||||
import { route } from '@/constants/routes'
|
||||
import { ExtensionProvider } from '@/providers/ExtensionProvider'
|
||||
import { ToasterProvider } from '@/providers/ToasterProvider'
|
||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||
import { PromptAnalytic } from '@/containers/analytics/PromptAnalytic'
|
||||
import { AnalyticProvider } from '@/providers/AnalyticProvider'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
})
|
||||
|
||||
const AppLayout = () => {
|
||||
const { productAnalyticPrompt } = useAnalytic()
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<AnalyticProvider />
|
||||
<KeyboardShortcutsProvider />
|
||||
<main className="relative h-svh text-sm antialiased select-none bg-app">
|
||||
{/* Fake absolute panel top to enable window drag */}
|
||||
@ -37,6 +43,7 @@ const AppLayout = () => {
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{productAnalyticPrompt && <PromptAnalytic />}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,6 +5,8 @@ import HeaderPage from '@/containers/HeaderPage'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardItem } from '@/containers/Card'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Route = createFileRoute(route.settings.privacy as any)({
|
||||
@ -13,6 +15,7 @@ export const Route = createFileRoute(route.settings.privacy as any)({
|
||||
|
||||
function Privacy() {
|
||||
const { t } = useTranslation()
|
||||
const { setProductAnalytic, productAnalytic } = useAnalytic()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@ -30,7 +33,17 @@ function Privacy() {
|
||||
Analytics
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch />
|
||||
<Switch
|
||||
checked={productAnalytic}
|
||||
onCheckedChange={(state) => {
|
||||
if (state) {
|
||||
posthog.opt_in_capturing()
|
||||
} else {
|
||||
posthog.opt_out_capturing()
|
||||
}
|
||||
setProductAnalytic(state)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
24
web-app/src/services/analytic.ts
Normal file
24
web-app/src/services/analytic.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { AppConfiguration } from '@janhq/core'
|
||||
|
||||
/**
|
||||
* Update app distinct Id
|
||||
* @param id
|
||||
*/
|
||||
export const updateDistinctId = async (id: string) => {
|
||||
const appConfiguration: AppConfiguration =
|
||||
await window.core?.api?.getAppConfigurations()
|
||||
appConfiguration.distinct_id = id
|
||||
await window.core?.api?.updateAppConfiguration({
|
||||
configuration: appConfiguration,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve app distinct Id
|
||||
* @param id
|
||||
*/
|
||||
export const getAppDistinctId = async (): Promise<string | undefined> => {
|
||||
const appConfiguration: AppConfiguration =
|
||||
await window.core?.api?.getAppConfigurations()
|
||||
return appConfiguration.distinct_id
|
||||
}
|
||||
2
web-app/src/types/global.d.ts
vendored
2
web-app/src/types/global.d.ts
vendored
@ -16,6 +16,8 @@ declare global {
|
||||
declare const IS_ANDROID: boolean
|
||||
declare const PLATFORM: string
|
||||
declare const VERSION: string
|
||||
declare const POSTHOG_KEY: string
|
||||
declare const POSTHOG_HOST: string
|
||||
interface Window {
|
||||
core: AppCore | undefined
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
@ -8,61 +8,69 @@ import packageJson from './package.json'
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
nodePolyfills({
|
||||
include: ['path'],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
IS_TAURI: JSON.stringify(process.env.IS_TAURI),
|
||||
IS_MACOS: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('darwin') ?? 'false'
|
||||
),
|
||||
IS_WINDOWS: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('windows') ?? 'false'
|
||||
),
|
||||
IS_LINUX: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('unix') ?? 'false'
|
||||
),
|
||||
IS_IOS: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('ios') ?? 'false'
|
||||
),
|
||||
IS_ANDROID: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('android') ?? 'false'
|
||||
),
|
||||
PLATFORM: JSON.stringify(process.env.TAURI_ENV_PLATFORM),
|
||||
export default defineConfig(({ mode }) => {
|
||||
// Load env file based on `mode` in the current working directory.
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
VERSION: JSON.stringify(packageJson.version),
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ['**/src-tauri/**'],
|
||||
return {
|
||||
plugins: [
|
||||
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
||||
react(),
|
||||
tailwindcss(),
|
||||
nodePolyfills({
|
||||
include: ['path'],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
IS_TAURI: JSON.stringify(process.env.IS_TAURI),
|
||||
IS_MACOS: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('darwin') ?? 'false'
|
||||
),
|
||||
IS_WINDOWS: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('windows') ?? 'false'
|
||||
),
|
||||
IS_LINUX: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('unix') ?? 'false'
|
||||
),
|
||||
IS_IOS: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('ios') ?? 'false'
|
||||
),
|
||||
IS_ANDROID: JSON.stringify(
|
||||
process.env.TAURI_ENV_PLATFORM?.includes('android') ?? 'false'
|
||||
),
|
||||
PLATFORM: JSON.stringify(process.env.TAURI_ENV_PLATFORM),
|
||||
|
||||
VERSION: JSON.stringify(packageJson.version),
|
||||
|
||||
POSTHOG_KEY: JSON.stringify(env.POSTHOG_KEY),
|
||||
POSTHOG_HOST: JSON.stringify(env.POSTHOG_HOST),
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell vite to ignore watching `src-tauri`
|
||||
ignored: ['**/src-tauri/**'],
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user