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(
-
-
+
+
+
+