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",
|
"lucide-react": "^0.503.0",
|
||||||
"motion": "^12.10.5",
|
"motion": "^12.10.5",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"posthog-js": "^1.246.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@ -11,4 +11,6 @@ export const localStorageKey = {
|
|||||||
settingMCPSevers: 'setting-mcp-servers',
|
settingMCPSevers: 'setting-mcp-servers',
|
||||||
settingLocalApiServer: 'setting-local-api-server',
|
settingLocalApiServer: 'setting-local-api-server',
|
||||||
settingHardware: 'setting-hardware',
|
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 { route } from '@/constants/routes'
|
||||||
import { ExtensionProvider } from '@/providers/ExtensionProvider'
|
import { ExtensionProvider } from '@/providers/ExtensionProvider'
|
||||||
import { ToasterProvider } from '@/providers/ToasterProvider'
|
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({
|
export const Route = createRootRoute({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
})
|
})
|
||||||
|
|
||||||
const AppLayout = () => {
|
const AppLayout = () => {
|
||||||
|
const { productAnalyticPrompt } = useAnalytic()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
|
<AnalyticProvider />
|
||||||
<KeyboardShortcutsProvider />
|
<KeyboardShortcutsProvider />
|
||||||
<main className="relative h-svh text-sm antialiased select-none bg-app">
|
<main className="relative h-svh text-sm antialiased select-none bg-app">
|
||||||
{/* Fake absolute panel top to enable window drag */}
|
{/* Fake absolute panel top to enable window drag */}
|
||||||
@ -37,6 +43,7 @@ const AppLayout = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
{productAnalyticPrompt && <PromptAnalytic />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import HeaderPage from '@/containers/HeaderPage'
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Card, CardItem } from '@/containers/Card'
|
import { Card, CardItem } from '@/containers/Card'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useAnalytic } from '@/hooks/useAnalytic'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const Route = createFileRoute(route.settings.privacy as 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() {
|
function Privacy() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const { setProductAnalytic, productAnalytic } = useAnalytic()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@ -30,7 +33,17 @@ function Privacy() {
|
|||||||
Analytics
|
Analytics
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
</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 IS_ANDROID: boolean
|
||||||
declare const PLATFORM: string
|
declare const PLATFORM: string
|
||||||
declare const VERSION: string
|
declare const VERSION: string
|
||||||
|
declare const POSTHOG_KEY: string
|
||||||
|
declare const POSTHOG_HOST: string
|
||||||
interface Window {
|
interface Window {
|
||||||
core: AppCore | undefined
|
core: AppCore | undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
@ -8,7 +8,11 @@ import packageJson from './package.json'
|
|||||||
const host = process.env.TAURI_DEV_HOST
|
const host = process.env.TAURI_DEV_HOST
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
// Load env file based on `mode` in the current working directory.
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
TanStackRouterVite({ target: 'react', autoCodeSplitting: true }),
|
||||||
react(),
|
react(),
|
||||||
@ -42,6 +46,9 @@ export default defineConfig({
|
|||||||
PLATFORM: JSON.stringify(process.env.TAURI_ENV_PLATFORM),
|
PLATFORM: JSON.stringify(process.env.TAURI_ENV_PLATFORM),
|
||||||
|
|
||||||
VERSION: JSON.stringify(packageJson.version),
|
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`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
@ -65,4 +72,5 @@ export default defineConfig({
|
|||||||
ignored: ['**/src-tauri/**'],
|
ignored: ['**/src-tauri/**'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user