diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 48d24c3..bc54a4a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -75,6 +75,9 @@ jobs: labels: ${{ steps.meta.outputs.labels }} build-args: | VITE_WC_PROJECT_ID=${{ secrets.VITE_WC_PROJECT_ID }} + VITE_ENABLE_ANALYTICS=${{ secrets.VITE_ENABLE_ANALYTICS }} + VITE_UMAMI_PROJECT_ID=${{ secrets.VITE_UMAMI_PROJECT_ID }} + VITE_UMAMI_URL=${{ secrets.VITE_UMAMI_URL }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false diff --git a/Dockerfile b/Dockerfile index e323198..744176b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,15 @@ COPY . . ARG VITE_WC_PROJECT_ID="" ENV VITE_WC_PROJECT_ID=${VITE_WC_PROJECT_ID} +ARG VITE_ENABLE_ANALYTICS="" +ENV VITE_ENABLE_ANALYTICS=${VITE_ENABLE_ANALYTICS} + +ARG VITE_UMAMI_PROJECT_ID="" +ENV VITE_UMAMI_PROJECT_ID=${VITE_UMAMI_PROJECT_ID} + +ARG VITE_UMAMI_URL="" +ENV VITE_UMAMI_URL=${VITE_UMAMI_URL} + RUN pnpm build # ─── Stage 2: runtime ─────────────────────────────────────────────────────── diff --git a/README.md b/README.md index a439d29..6aaa37a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,24 @@ All state lives in `localStorage` under the `tl-ui:*` namespace: Clearing site data resets the app completely. +## Usage analytics (Umami) + +The app can send anonymous usage analytics to [Umami](https://umami.is/), an open-source, privacy-friendly alternative to Google Analytics. + +To enable it, set the following variables: + +```properties +VITE_ENABLE_ANALYTICS="true" +VITE_UMAMI_PROJECT_ID="umami-project-id" +VITE_UMAMI_URL="link-to-umami-js-script" +``` + +| Variable | Description | +|---|---| +| `VITE_ENABLE_ANALYTICS` | Toggles analytics on or off. Set to `"true"` to enable; leave unset or `"false"` to disable. | +| `VITE_UMAMI_PROJECT_ID` | Project ID from your Umami dashboard. | +| `VITE_UMAMI_URL` | URL from which the Umami tracking script is loaded. | + ## Contributing Pull requests are welcome. The codebase is small and self-contained: no Solidity, no backend, just a frontend talking to the chain through viem. diff --git a/src/App.tsx b/src/App.tsx index 34be349..d9329ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect } from 'react' import { Routes, Route, NavLink } from 'react-router-dom' import { ConnectButton } from '@rainbow-me/rainbowkit' -import { useChainId, useSwitchChain } from 'wagmi' +import { useAccount, useChainId, useSwitchChain } from 'wagmi' import { Settings2, List, Info, Wallet } from 'lucide-react' import { Operations } from './pages/Operations' import { NewOperation } from './pages/NewOperation' @@ -11,6 +11,7 @@ import { NetworkSelector } from './components/NetworkSelector' import { TimelockSelector } from './components/TimelockSelector' import { useTimelockStore } from './contexts/timelocks' import { useNetworks } from './hooks/useNetworks' +import { useAnalytics } from './analytics/analytics' function WalletButton() { return ( @@ -46,9 +47,11 @@ function WalletButton() { export default function App() { const { timelocks, activeAddress, selectTimelock } = useTimelockStore() + const {address} = useAccount(); const { networks } = useNetworks() const walletChainId = useChainId() const { switchChain } = useSwitchChain() + const {sendEvent} = useAnalytics(); const visibleTimelocks = timelocks.filter((t) => t.chainId === walletChainId) @@ -63,6 +66,14 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletChainId]) + useEffect(() => { + if (!address) { + return; + } + + sendEvent('wallet_connected'); + }, [address]); + const handleSelectNetwork = useCallback( (chainId: number) => switchChain({ chainId }), [switchChain], @@ -179,6 +190,14 @@ export default function App() { > Contribute on GitHub + + Get in touch + ) => void; +}; + +const AnalyticsContext = createContext(null); + +type UmamiWindow = Window & { + umami: + | { + track: (str: string, args: Record) => void; + } + | undefined; +}; + +export const AnalyticsProvider = ({ children }: { children: ReactNode }) => { + const enabled: boolean = import.meta.env.VITE_ENABLE_ANALYTICS === "true"; + + useEffect(() => { + if (!enabled) { + return; + } + injectUmamiScript(); + }, []); + + const sendEvent = (event: string, data: Record = {}) => { + if (!enabled) { + return; + } + const umami = (window as unknown as UmamiWindow).umami; + if (!umami) { + return; + } + + umami.track(event, data); + }; + + const injectUmamiScript = () => { + const script = document.createElement('script'); + script.defer = true; + script.src = import.meta.env.VITE_UMAMI_URL; + script.setAttribute('data-website-id', import.meta.env.VITE_UMAMI_PROJECT_ID); + document.head.appendChild(script); + }; + + return ( + + {children} + + ); +}; + + +export const useAnalytics = () => { + const context = useContext(AnalyticsContext); + if (!context) { + throw new Error("useAnalytics() must be used within AnalyticsProvider"); + } + + return context; +}; diff --git a/src/components/OperationCard.tsx b/src/components/OperationCard.tsx index c707ff9..d977571 100644 --- a/src/components/OperationCard.tsx +++ b/src/components/OperationCard.tsx @@ -10,6 +10,7 @@ import { OperationState, shortHex, explorerTxUrl, explorerAddressUrl, hashOperat import { RECEIPT_TIMEOUT_MS } from '../lib/connectors' import { useIsSafeWallet } from '../hooks/useIsSafeWallet' import type { StoredOperation } from '../lib/storage' +import { useAnalytics } from '../analytics/analytics' interface Props { operation: StoredOperation @@ -34,6 +35,7 @@ export function OperationCard({ operation, explorerUrl, onPatched, onToast }: Pr ) const { roles } = useTimelockRoles(operation.timelockAddress, userAddress, operation.chainId) const { writeContractAsync, isPending } = useWriteContract() + const {sendEvent} = useAnalytics(); const state = onChain?.state ?? 0 const readyAt = onChain?.readyAt ?? 0n @@ -46,6 +48,7 @@ export function OperationCard({ operation, explorerUrl, onPatched, onToast }: Pr setSaltError('Invalid salt: must be 0x followed by 64 hex characters') return } + sendEvent('save_salt_button_clicked'); // Verificar que el salt cuadra con el operation ID const computed = hashOperation( operation.target, @@ -64,6 +67,7 @@ export function OperationCard({ operation, explorerUrl, onPatched, onToast }: Pr async function handleExecute() { if (!operation.salt) return + sendEvent('execute_button_clicked'); try { const hash = await writeContractAsync({ address: operation.timelockAddress, diff --git a/src/components/SettingsNetworks.tsx b/src/components/SettingsNetworks.tsx index 61f8c10..7ea8a48 100644 --- a/src/components/SettingsNetworks.tsx +++ b/src/components/SettingsNetworks.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { Plus, Pencil, Trash2, Check, X } from 'lucide-react' import type { StoredNetwork } from '../lib/storage' +import { useAnalytics } from '../analytics/analytics' interface Props { networks: StoredNetwork[] @@ -18,11 +19,14 @@ const EMPTY: StoredNetwork = { } export function SettingsNetworks({ networks, onAdd, onUpdate, onRemove }: Props) { + const {sendEvent} = useAnalytics(); + const [showForm, setShowForm] = useState(false) const [editId, setEditId] = useState(null) const [form, setForm] = useState(EMPTY) function openAdd() { + sendEvent('add_network_button_clicked'); setForm(EMPTY) setEditId(null) setShowForm(true) @@ -41,6 +45,9 @@ export function SettingsNetworks({ networks, onAdd, onUpdate, onRemove }: Props) function handleSave() { if (!form.name || !form.rpcUrl || form.chainId <= 0) return + + sendEvent('save_network_button_clicked'); + if (editId !== null) { onUpdate(editId, form) } else { diff --git a/src/components/SettingsTimelocks.tsx b/src/components/SettingsTimelocks.tsx index d701943..c8f502d 100644 --- a/src/components/SettingsTimelocks.tsx +++ b/src/components/SettingsTimelocks.tsx @@ -6,6 +6,7 @@ import type { StoredNetwork } from '../lib/storage' import { setSyncCursor } from '../lib/storage' import { shortHex, findDeployBlock } from '../lib/timelock' import { timelockAbi } from '../abis/timelock' +import { useAnalytics } from '../analytics/analytics' interface Props { timelocks: StoredTimelock[] @@ -28,6 +29,9 @@ export function SettingsTimelocks({ onRemove, onSelect, }: Props) { + + const {sendEvent} = useAnalytics(); + const [showForm, setShowForm] = useState(false) const [editAddress, setEditAddress] = useState<`0x${string}` | null>(null) const [form, setForm] = useState(EMPTY) @@ -36,6 +40,7 @@ export function SettingsTimelocks({ const [validationError, setValidationError] = useState(null) function openAdd() { + sendEvent('add_timelock_button_clicked'); setForm({ ...EMPTY, chainId: networks[0]?.chainId ?? 1 }) setEditAddress(null) setValidationError(null) @@ -58,6 +63,7 @@ export function SettingsTimelocks({ async function handleSave() { if (!form.name || !isAddress(form.address)) return + sendEvent('save_timelock_button_clicked'); let toSave: StoredTimelock = form const addressChanged = editAddress === null || editAddress.toLowerCase() !== form.address.toLowerCase() diff --git a/src/hooks/useChainSync.ts b/src/hooks/useChainSync.ts index 606d1bb..6cf9ed6 100644 --- a/src/hooks/useChainSync.ts +++ b/src/hooks/useChainSync.ts @@ -8,6 +8,7 @@ import { setSyncCursor, type StoredOperation, } from '../lib/storage' +import { useAnalytics } from '../analytics/analytics' const CALL_SCHEDULED_EVENT = parseAbiItem( 'event CallScheduled(bytes32 indexed id, uint256 indexed index, address target, uint256 value, bytes data, bytes32 predecessor, uint256 delay)', @@ -57,6 +58,7 @@ export function useChainSync( chainId: number | undefined, onSynced: () => void, ) { + const {sendEvent} = useAnalytics(); const [isSyncing, setIsSyncing] = useState(false) const [syncResult, setSyncResult] = useState(null) const [syncError, setSyncError] = useState(null) @@ -71,6 +73,8 @@ export function useChainSync( const sync = useCallback(async () => { if (!timelockAddress || !chainId || !client) return + + sendEvent('scan_operations_button_clicked'); setIsSyncing(true) setSyncError(null) setSyncResult(null) diff --git a/src/main.tsx b/src/main.tsx index dd158e3..7af7945 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -9,6 +9,7 @@ import './index.css' import App from './App.tsx' import { buildWagmiConfig } from './lib/wagmi' import { TimelockProvider } from './contexts/timelocks' +import { AnalyticsProvider } from './analytics/analytics.tsx' const wagmiConfig = buildWagmiConfig() const queryClient = new QueryClient() @@ -19,9 +20,11 @@ createRoot(document.getElementById('root')!).render( - - + + + +