Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 20 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,6 +11,7 @@
import { TimelockSelector } from './components/TimelockSelector'
import { useTimelockStore } from './contexts/timelocks'
import { useNetworks } from './hooks/useNetworks'
import { useAnalytics } from './analytics/analytics'

function WalletButton() {
return (
Expand Down Expand Up @@ -46,9 +47,11 @@

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)

Expand All @@ -63,6 +66,14 @@
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [walletChainId])

useEffect(() => {
if (!address) {
return;
}

sendEvent('wallet_connected');
}, [address]);

Check warning on line 75 in src/App.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

React Hook useEffect has a missing dependency: 'sendEvent'. Either include it or remove the dependency array

const handleSelectNetwork = useCallback(
(chainId: number) => switchChain({ chainId }),
[switchChain],
Expand Down Expand Up @@ -179,6 +190,14 @@
>
Contribute on GitHub
</a>
<a
href="https://stakely.io/resources/contact-us"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-200 transition-colors"
>
Get in touch
</a>
<a
href={`https://github.com/Stakely/timelock-ui/releases/tag/v${__APP_VERSION__}`}
target="_blank"
Expand Down
66 changes: 66 additions & 0 deletions src/analytics/analytics.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createContext, useContext, useEffect, type ReactNode } from "react";

type AnalyticsContextType = {
sendEvent: (event: string, data?: Record<string, string>) => void;
};

const AnalyticsContext = createContext<AnalyticsContextType | null>(null);

type UmamiWindow = Window & {
umami:
| {
track: (str: string, args: Record<string, string>) => 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<string, string> = {}) => {
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 (
<AnalyticsContext.Provider
value={{
sendEvent,
}}
>
{children}
</AnalyticsContext.Provider>
);
};


export const useAnalytics = () => {
const context = useContext(AnalyticsContext);
if (!context) {
throw new Error("useAnalytics() must be used within AnalyticsProvider");
}

return context;
};
4 changes: 4 additions & 0 deletions src/components/OperationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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
Expand All @@ -34,6 +35,7 @@
)
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
Expand All @@ -46,6 +48,7 @@
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,
Expand All @@ -64,6 +67,7 @@

async function handleExecute() {
if (!operation.salt) return
sendEvent('execute_button_clicked');
try {
const hash = await writeContractAsync({
address: operation.timelockAddress,
Expand Down Expand Up @@ -106,7 +110,7 @@
})
}
refetch()
} catch (e: any) {

Check failure on line 113 in src/components/OperationCard.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

Unexpected any. Specify a different type
onToast({ message: e?.shortMessage ?? e?.message ?? 'Unknown error', type: 'error' })
} finally {
setTxPending(false)
Expand Down Expand Up @@ -147,7 +151,7 @@
})
}
refetch()
} catch (e: any) {

Check failure on line 154 in src/components/OperationCard.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

Unexpected any. Specify a different type
onToast({ message: e?.shortMessage ?? e?.message ?? 'Unknown error', type: 'error' })
} finally {
setTxPending(false)
Expand Down
7 changes: 7 additions & 0 deletions src/components/SettingsNetworks.tsx
Original file line number Diff line number Diff line change
@@ -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[]
Expand All @@ -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<number | null>(null)
const [form, setForm] = useState<StoredNetwork>(EMPTY)

function openAdd() {
sendEvent('add_network_button_clicked');
setForm(EMPTY)
setEditId(null)
setShowForm(true)
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions src/components/SettingsTimelocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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[]
Expand All @@ -28,6 +29,9 @@
onRemove,
onSelect,
}: Props) {

const {sendEvent} = useAnalytics();

Check failure on line 33 in src/components/SettingsTimelocks.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

Irregular whitespace not allowed
Comment thread
PedroCM96 marked this conversation as resolved.

const [showForm, setShowForm] = useState(false)
const [editAddress, setEditAddress] = useState<`0x${string}` | null>(null)
const [form, setForm] = useState<StoredTimelock>(EMPTY)
Expand All @@ -36,6 +40,7 @@
const [validationError, setValidationError] = useState<string | null>(null)

function openAdd() {
sendEvent('add_timelock_button_clicked');
setForm({ ...EMPTY, chainId: networks[0]?.chainId ?? 1 })
setEditAddress(null)
setValidationError(null)
Expand All @@ -58,6 +63,7 @@
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()
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/useChainSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
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)',
Expand Down Expand Up @@ -57,6 +58,7 @@
chainId: number | undefined,
onSynced: () => void,
) {
const {sendEvent} = useAnalytics();
const [isSyncing, setIsSyncing] = useState(false)
const [syncResult, setSyncResult] = useState<SyncResult | null>(null)
const [syncError, setSyncError] = useState<string | null>(null)
Expand All @@ -71,6 +73,8 @@

const sync = useCallback(async () => {
if (!timelockAddress || !chainId || !client) return

sendEvent('scan_operations_button_clicked');
setIsSyncing(true)
setSyncError(null)
setSyncResult(null)
Expand Down Expand Up @@ -186,7 +190,7 @@
} finally {
setIsSyncing(false)
}
}, [timelockAddress, chainId, client, onSynced])

Check warning on line 193 in src/hooks/useChainSync.ts

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

React Hook useCallback has a missing dependency: 'sendEvent'. Either include it or remove the dependency array

return { sync, isSyncing, syncResult, syncError, progress, cursor }
}
7 changes: 5 additions & 2 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -19,9 +20,11 @@ createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<RainbowKitProvider theme={darkTheme()}>
<BrowserRouter>
<TimelockProvider>
<App />
<AnalyticsProvider>
<TimelockProvider>
<App />
</TimelockProvider>
</AnalyticsProvider>
</BrowserRouter>
</RainbowKitProvider>
</QueryClientProvider>
Expand Down
Loading