diff --git a/.github/workflows/release-manual.yml b/.github/workflows/release-manual.yml new file mode 100644 index 00000000..00fd9658 --- /dev/null +++ b/.github/workflows/release-manual.yml @@ -0,0 +1,85 @@ +name: Release Manual + +on: + workflow_dispatch: + inputs: + deploy_production: + description: Deploy production on Vercel + required: true + default: true + type: boolean + +permissions: + contents: read + +jobs: + preflight: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9.15.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm typecheck + + release: + if: ${{ inputs.deploy_production }} + needs: preflight + runs-on: ubuntu-latest + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} + VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} + PRODUCTION_HEALTHCHECK_URL: ${{ secrets.PRODUCTION_HEALTHCHECK_URL }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Validate deploy secrets + run: | + missing=0 + for key in VERCEL_TOKEN VERCEL_ORG_ID VERCEL_PROJECT_ID; do + if [ -z "${!key}" ]; then + echo "::error::Missing ${key} secret" + missing=1 + fi + done + if [ "${missing}" -ne 0 ]; then + exit 1 + fi + + - name: Install Vercel CLI + run: npm install -g vercel@latest + + - name: Pull project settings + run: vercel pull --yes --environment=production --token="${VERCEL_TOKEN}" + + - name: Build deployment artifact + run: vercel build --prod --token="${VERCEL_TOKEN}" + + - name: Deploy production artifact + run: vercel deploy --prebuilt --prod --yes --token="${VERCEL_TOKEN}" + + - name: Smoke check + if: ${{ env.PRODUCTION_HEALTHCHECK_URL != '' }} + run: | + curl --fail --silent --show-error "${PRODUCTION_HEALTHCHECK_URL}" diff --git a/app/admin/stats-v2/page.tsx b/app/admin/stats-v2/page.tsx index fc1dab82..c038b0c8 100644 --- a/app/admin/stats-v2/page.tsx +++ b/app/admin/stats-v2/page.tsx @@ -1,172 +1,16 @@ -'use client'; +import { Suspense } from 'react'; +import StatsV2PageClient from './stats-v2-page-client'; -/** - * Stats V2 Dashboard (Experimental) - * - * This page uses a new cross-chain indexer API that provides Monarch transaction - * data across all chains with a single API call. - * - * NOTE: This API is experimental and may be reverted due to cost concerns. - * The old stats page at /admin/stats should be kept as a fallback. - * - * Features: - * - Cross-chain volume aggregation - * - Volume breakdown by chain - * - Supply and withdraw transaction tables - * - ETH/BTC price estimation for USD values - */ - -import { useState } from 'react'; -import { Button } from '@/components/ui/button'; -import ButtonGroup from '@/components/ui/button-group'; -import { Spinner } from '@/components/ui/spinner'; -import Header from '@/components/layout/header/Header'; -import { PasswordGate } from '@/features/admin-v2/components/password-gate'; -import { StatsOverviewCards } from '@/features/admin-v2/components/stats-overview-cards'; -import { StatsVolumeChart } from '@/features/admin-v2/components/stats-volume-chart'; -import { ChainVolumeChart } from '@/features/admin-v2/components/chain-volume-chart'; -import { StatsTransactionsTable } from '@/features/admin-v2/components/stats-transactions-table'; -import { StatsAssetTable } from '@/features/admin-v2/components/stats-asset-table'; -import { StatsMarketTable } from '@/features/admin-v2/components/stats-market-table'; -import { useMonarchTransactions, type TimeFrame } from '@/hooks/useMonarchTransactions'; -import { useAdminAuth } from '@/stores/useAdminAuth'; - -function StatsV2Content() { - const [timeframe, setTimeframe] = useState('30D'); - const { logout } = useAdminAuth(); - - const { - transactions, - supplies, - withdraws, - chainStats, - dailyVolumes, - totalSupplyVolumeUsd, - totalWithdrawVolumeUsd, - totalVolumeUsd, - isLoading, - error, - } = useMonarchTransactions(timeframe); - - const timeframeOptions = [ - { key: '1D', label: '1D', value: '1D' }, - { key: '7D', label: '7D', value: '7D' }, - { key: '30D', label: '30D', value: '30D' }, - { key: '90D', label: '90D', value: '90D' }, - { key: 'ALL', label: 'ALL', value: 'ALL' }, - ]; - - if (error) { - return ( -
-
-
-
-

Error Loading Data

-

{error.message}

- -
-
-
- ); - } - - return ( -
-
-
- {/* Header */} -
-
-
-

Monarch Stats

-
-
-
- setTimeframe(value as TimeFrame)} - size="sm" - variant="default" - /> - -
-
- - {isLoading ? ( -
- -
- ) : ( -
- {/* Overview Cards */} - - - {/* Charts Grid */} -
- {/* Aggregated Volume Chart */} - - - {/* Chain Breakdown Chart */} - -
- - {/* Transactions Table */} - - - {/* Asset Metrics Table */} - - - {/* Market Metrics Table */} - -
- )} -
-
- ); -} +const LoadingFallback = () => ( +
+
Loading statistics...
+
+); export default function StatsV2Page() { return ( - - - + }> + + ); } diff --git a/app/admin/stats-v2/stats-v2-page-client.tsx b/app/admin/stats-v2/stats-v2-page-client.tsx new file mode 100644 index 00000000..20c1ecf4 --- /dev/null +++ b/app/admin/stats-v2/stats-v2-page-client.tsx @@ -0,0 +1,182 @@ +'use client'; + +/** + * Stats V2 Dashboard (Experimental) + * + * This page uses a new cross-chain indexer API that provides Monarch transaction + * data across all chains with a single API call. + * + * NOTE: This API is experimental and may be reverted due to cost concerns. + * The old stats page at /admin/stats should be kept as a fallback. + * + * Features: + * - Cross-chain volume aggregation + * - Volume breakdown by chain + * - Supply and withdraw transaction tables + * - ETH/BTC price estimation for USD values + */ + +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import ButtonGroup from '@/components/ui/button-group'; +import { Spinner } from '@/components/ui/spinner'; +import Header from '@/components/layout/header/Header'; +import { PasswordGate } from '@/features/admin-v2/components/password-gate'; +import { StatsOverviewCards } from '@/features/admin-v2/components/stats-overview-cards'; +import { StatsVolumeChart } from '@/features/admin-v2/components/stats-volume-chart'; +import { ChainVolumeChart } from '@/features/admin-v2/components/chain-volume-chart'; +import { StatsAttributionOverview } from '@/features/admin-v2/components/stats-attribution-overview'; +import { StatsTransactionsTable } from '@/features/admin-v2/components/stats-transactions-table'; +import { StatsAssetTable } from '@/features/admin-v2/components/stats-asset-table'; +import { StatsMarketTable } from '@/features/admin-v2/components/stats-market-table'; +import { useAttributionScoreboard } from '@/hooks/useAttributionScoreboard'; +import { useMonarchTransactions, type TimeFrame } from '@/hooks/useMonarchTransactions'; +import { useAdminAuth } from '@/stores/useAdminAuth'; + +function StatsV2Content() { + const [timeframe, setTimeframe] = useState('30D'); + const { logout } = useAdminAuth(); + + const { + transactions, + supplies, + withdraws, + chainStats, + dailyVolumes, + totalSupplyVolumeUsd, + totalWithdrawVolumeUsd, + totalVolumeUsd, + isLoading, + error, + } = useMonarchTransactions(timeframe); + const attribution = useAttributionScoreboard(timeframe); + + const timeframeOptions = [ + { key: '1D', label: '1D', value: '1D' }, + { key: '7D', label: '7D', value: '7D' }, + { key: '30D', label: '30D', value: '30D' }, + { key: '90D', label: '90D', value: '90D' }, + { key: 'ALL', label: 'ALL', value: 'ALL' }, + ]; + + if (error) { + return ( +
+
+
+
+

Error Loading Data

+

{error.message}

+ +
+
+
+ ); + } + + return ( +
+
+
+ {/* Header */} +
+
+
+

Monarch Stats

+
+
+
+ setTimeframe(value as TimeFrame)} + size="sm" + variant="default" + /> + +
+
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {/* Overview Cards */} + + + + + {/* Charts Grid */} +
+ {/* Aggregated Volume Chart */} + + + {/* Chain Breakdown Chart */} + +
+ + {/* Transactions Table */} + + + {/* Asset Metrics Table */} + + + {/* Market Metrics Table */} + +
+ )} +
+
+ ); +} + +export default function StatsV2Page() { + return ( + + + + ); +} diff --git a/app/admin/stats/page.tsx b/app/admin/stats/page.tsx index d30a3336..0a904f61 100644 --- a/app/admin/stats/page.tsx +++ b/app/admin/stats/page.tsx @@ -1,369 +1,16 @@ -'use client'; +import { Suspense } from 'react'; +import StatsPageClient from './stats-page-client'; -import { useState, useEffect } from 'react'; -import { Button } from '@/components/ui/button'; -import Image from 'next/image'; -import ButtonGroup from '@/components/ui/button-group'; -import { Spinner } from '@/components/ui/spinner'; -import { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, -} from '@/components/ui/dropdown-menu'; -import { TokenIcon } from '@/components/shared/token-icon'; -import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; -import { fetchAllStatistics } from '@/services/statsService'; -import { SupportedNetworks, getNetworkImg, getNetworkName, getViemChain } from '@/utils/networks'; -import type { PlatformStats, TimeFrame, AssetVolumeData, Transaction } from '@/utils/statsUtils'; -import type { ERC20Token, UnknownERC20Token, TokenSource } from '@/utils/tokens'; -import { findToken as findTokenStatic } from '@/utils/tokens'; -import { AssetMetricsTable } from '@/features/admin/components/asset-metrics-table'; -import { StatsOverviewCards } from '@/features/admin/components/stats-overview-cards'; -import { TransactionsTable } from '@/features/admin/components/transactions-table'; - -const getAPIEndpoint = (network: SupportedNetworks) => { - switch (network) { - case SupportedNetworks.Base: - return 'https://api.studio.thegraph.com/query/94369/monarch-metrics/version/latest'; - case SupportedNetworks.Mainnet: - return 'https://api.studio.thegraph.com/query/110397/monarch-metrics-mainnet/version/latest'; - default: - return undefined; - } -}; +const LoadingFallback = () => ( +
+
Loading statistics...
+
+); export default function StatsPage() { - const [timeframe, setTimeframe] = useState('30D'); - const [selectedNetwork, setSelectedNetwork] = useState(SupportedNetworks.Base); - const [isLoading, setIsLoading] = useState(true); - const [selectedLoanAssets, setSelectedLoanAssets] = useState([]); - const [selectedSides, setSelectedSides] = useState<('Supply' | 'Withdraw')[]>([]); - const [uniqueLoanAssets, setUniqueLoanAssets] = useState<(ERC20Token | UnknownERC20Token)[]>([]); - const [stats, setStats] = useState<{ - platformStats: PlatformStats; - assetMetrics: AssetVolumeData[]; - transactions: Transaction[]; - }>({ - platformStats: { - uniqueUsers: 0, - uniqueUsersDelta: 0, - totalTransactions: 0, - totalTransactionsDelta: 0, - supplyCount: 0, - supplyCountDelta: 0, - withdrawCount: 0, - withdrawCountDelta: 0, - activeMarkets: 0, - }, - assetMetrics: [], - transactions: [], - }); - - const { allMarkets } = useProcessedMarkets(); - - useEffect(() => { - const loadStats = async () => { - setIsLoading(true); - try { - console.log(`Fetching statistics for timeframe: ${timeframe}, network: ${getNetworkName(selectedNetwork) ?? 'Unknown'}`); - const startTime = performance.now(); - - // Get API endpoint for the selected network - const apiEndpoint = getAPIEndpoint(selectedNetwork); - if (!apiEndpoint) { - throw new Error(`Unsupported network: ${selectedNetwork}`); - } - console.log(`Using API endpoint: ${apiEndpoint}`); - - const allStats = await fetchAllStatistics(selectedNetwork, apiEndpoint, timeframe); - - const endTime = performance.now(); - console.log(`Statistics fetched in ${endTime - startTime}ms:`, allStats); - - console.log('Platform stats:', allStats.platformStats); - console.log('Asset metrics count:', allStats.assetMetrics.length); - - setStats({ - platformStats: allStats.platformStats, - assetMetrics: allStats.assetMetrics, - transactions: allStats.transactions, - }); - } catch (error) { - console.error('Error loading stats:', error); - } finally { - setIsLoading(false); - } - }; - - void loadStats(); - }, [timeframe, selectedNetwork]); - - // Extract unique loan assets from transactions - useEffect(() => { - if (stats.transactions.length === 0) { - setUniqueLoanAssets([]); - return; - } - - const loanAssetsMap = new Map(); - - stats.transactions.forEach((tx) => { - // Extract from supplies - tx.supplies?.forEach((supply) => { - if (supply.market?.loan) { - const address = supply.market.loan.toLowerCase(); - if (!loanAssetsMap.has(address)) { - const token = findTokenStatic(address, selectedNetwork); - if (token) { - loanAssetsMap.set(address, { - address, - symbol: token.symbol, - decimals: token.decimals, - }); - } - } - } - }); - - // Extract from withdrawals - tx.withdrawals?.forEach((withdrawal) => { - if (withdrawal.market?.loan) { - const address = withdrawal.market.loan.toLowerCase(); - if (!loanAssetsMap.has(address)) { - const token = findTokenStatic(address, selectedNetwork); - if (token) { - loanAssetsMap.set(address, { - address, - symbol: token.symbol, - decimals: token.decimals, - }); - } - } - } - }); - }); - - // Convert to ERC20Token format - const tokens: ERC20Token[] = Array.from(loanAssetsMap.values()).map((asset) => { - const fullToken = findTokenStatic(asset.address, selectedNetwork); - return { - symbol: asset.symbol, - img: fullToken?.img, - decimals: asset.decimals, - networks: [ - { - chain: getViemChain(selectedNetwork), - address: asset.address, - }, - ], - source: 'local' as TokenSource, - }; - }); - - setUniqueLoanAssets(tokens); - }, [stats.transactions, selectedNetwork]); - - const timeframeOptions = [ - { key: '1D', label: '1D', value: '1D' }, - { key: '7D', label: '7D', value: '7D' }, - { key: '30D', label: '30D', value: '30D' }, - { key: '90D', label: '90D', value: '90D' }, - { key: 'ALL', label: 'ALL', value: 'ALL' }, - ]; - - // Get network image for selected network with fallback - const selectedNetworkImg = getNetworkImg(selectedNetwork); - // Get network names - const baseNetworkName = getNetworkName(SupportedNetworks.Base); - const mainnetNetworkName = getNetworkName(SupportedNetworks.Mainnet); - return ( -
-
-

Platform Statistics

-
- {/* Network selector */} - - - - - - setSelectedNetwork(SupportedNetworks.Base)} - startContent={ - getNetworkImg(SupportedNetworks.Base) && ( - {baseNetworkName - ) - } - className="py-2" - > - {baseNetworkName} - - setSelectedNetwork(SupportedNetworks.Mainnet)} - startContent={ - getNetworkImg(SupportedNetworks.Mainnet) && ( - {mainnetNetworkName - ) - } - className="py-2" - > - {mainnetNetworkName} - - - - - {/* Timeframe selector */} - setTimeframe(value as TimeFrame)} - size="sm" - variant="default" - /> -
-
- - {isLoading ? ( -
- -
- ) : ( -
- - - - {/* Transaction Filters */} -
- {/* Loan Asset Filter */} - - - - - - {uniqueLoanAssets.map((asset) => { - const assetKey = asset.networks.map((n) => `${n.address}-${n.chain.id}`).join('|'); - const firstNetwork = asset.networks[0]; - - return ( - { - if (checked) { - setSelectedLoanAssets([...selectedLoanAssets, assetKey]); - } else { - setSelectedLoanAssets(selectedLoanAssets.filter((k) => k !== assetKey)); - } - }} - className="py-2" - startContent={ - - } - > - {asset.symbol} - - ); - })} - - - - {/* Side Filter */} - - - - - - { - if (checked) { - setSelectedSides([...selectedSides, 'Supply']); - } else { - setSelectedSides(selectedSides.filter((s) => s !== 'Supply')); - } - }} - className="py-2" - > - Supply - - { - if (checked) { - setSelectedSides([...selectedSides, 'Withdraw']); - } else { - setSelectedSides(selectedSides.filter((s) => s !== 'Withdraw')); - } - }} - className="py-2" - > - Withdraw - - - -
- - -
- )} -
+ }> + + ); } diff --git a/app/admin/stats/stats-page-client.tsx b/app/admin/stats/stats-page-client.tsx new file mode 100644 index 00000000..d30a3336 --- /dev/null +++ b/app/admin/stats/stats-page-client.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import Image from 'next/image'; +import ButtonGroup from '@/components/ui/button-group'; +import { Spinner } from '@/components/ui/spinner'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, +} from '@/components/ui/dropdown-menu'; +import { TokenIcon } from '@/components/shared/token-icon'; +import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; +import { fetchAllStatistics } from '@/services/statsService'; +import { SupportedNetworks, getNetworkImg, getNetworkName, getViemChain } from '@/utils/networks'; +import type { PlatformStats, TimeFrame, AssetVolumeData, Transaction } from '@/utils/statsUtils'; +import type { ERC20Token, UnknownERC20Token, TokenSource } from '@/utils/tokens'; +import { findToken as findTokenStatic } from '@/utils/tokens'; +import { AssetMetricsTable } from '@/features/admin/components/asset-metrics-table'; +import { StatsOverviewCards } from '@/features/admin/components/stats-overview-cards'; +import { TransactionsTable } from '@/features/admin/components/transactions-table'; + +const getAPIEndpoint = (network: SupportedNetworks) => { + switch (network) { + case SupportedNetworks.Base: + return 'https://api.studio.thegraph.com/query/94369/monarch-metrics/version/latest'; + case SupportedNetworks.Mainnet: + return 'https://api.studio.thegraph.com/query/110397/monarch-metrics-mainnet/version/latest'; + default: + return undefined; + } +}; + +export default function StatsPage() { + const [timeframe, setTimeframe] = useState('30D'); + const [selectedNetwork, setSelectedNetwork] = useState(SupportedNetworks.Base); + const [isLoading, setIsLoading] = useState(true); + const [selectedLoanAssets, setSelectedLoanAssets] = useState([]); + const [selectedSides, setSelectedSides] = useState<('Supply' | 'Withdraw')[]>([]); + const [uniqueLoanAssets, setUniqueLoanAssets] = useState<(ERC20Token | UnknownERC20Token)[]>([]); + const [stats, setStats] = useState<{ + platformStats: PlatformStats; + assetMetrics: AssetVolumeData[]; + transactions: Transaction[]; + }>({ + platformStats: { + uniqueUsers: 0, + uniqueUsersDelta: 0, + totalTransactions: 0, + totalTransactionsDelta: 0, + supplyCount: 0, + supplyCountDelta: 0, + withdrawCount: 0, + withdrawCountDelta: 0, + activeMarkets: 0, + }, + assetMetrics: [], + transactions: [], + }); + + const { allMarkets } = useProcessedMarkets(); + + useEffect(() => { + const loadStats = async () => { + setIsLoading(true); + try { + console.log(`Fetching statistics for timeframe: ${timeframe}, network: ${getNetworkName(selectedNetwork) ?? 'Unknown'}`); + const startTime = performance.now(); + + // Get API endpoint for the selected network + const apiEndpoint = getAPIEndpoint(selectedNetwork); + if (!apiEndpoint) { + throw new Error(`Unsupported network: ${selectedNetwork}`); + } + console.log(`Using API endpoint: ${apiEndpoint}`); + + const allStats = await fetchAllStatistics(selectedNetwork, apiEndpoint, timeframe); + + const endTime = performance.now(); + console.log(`Statistics fetched in ${endTime - startTime}ms:`, allStats); + + console.log('Platform stats:', allStats.platformStats); + console.log('Asset metrics count:', allStats.assetMetrics.length); + + setStats({ + platformStats: allStats.platformStats, + assetMetrics: allStats.assetMetrics, + transactions: allStats.transactions, + }); + } catch (error) { + console.error('Error loading stats:', error); + } finally { + setIsLoading(false); + } + }; + + void loadStats(); + }, [timeframe, selectedNetwork]); + + // Extract unique loan assets from transactions + useEffect(() => { + if (stats.transactions.length === 0) { + setUniqueLoanAssets([]); + return; + } + + const loanAssetsMap = new Map(); + + stats.transactions.forEach((tx) => { + // Extract from supplies + tx.supplies?.forEach((supply) => { + if (supply.market?.loan) { + const address = supply.market.loan.toLowerCase(); + if (!loanAssetsMap.has(address)) { + const token = findTokenStatic(address, selectedNetwork); + if (token) { + loanAssetsMap.set(address, { + address, + symbol: token.symbol, + decimals: token.decimals, + }); + } + } + } + }); + + // Extract from withdrawals + tx.withdrawals?.forEach((withdrawal) => { + if (withdrawal.market?.loan) { + const address = withdrawal.market.loan.toLowerCase(); + if (!loanAssetsMap.has(address)) { + const token = findTokenStatic(address, selectedNetwork); + if (token) { + loanAssetsMap.set(address, { + address, + symbol: token.symbol, + decimals: token.decimals, + }); + } + } + } + }); + }); + + // Convert to ERC20Token format + const tokens: ERC20Token[] = Array.from(loanAssetsMap.values()).map((asset) => { + const fullToken = findTokenStatic(asset.address, selectedNetwork); + return { + symbol: asset.symbol, + img: fullToken?.img, + decimals: asset.decimals, + networks: [ + { + chain: getViemChain(selectedNetwork), + address: asset.address, + }, + ], + source: 'local' as TokenSource, + }; + }); + + setUniqueLoanAssets(tokens); + }, [stats.transactions, selectedNetwork]); + + const timeframeOptions = [ + { key: '1D', label: '1D', value: '1D' }, + { key: '7D', label: '7D', value: '7D' }, + { key: '30D', label: '30D', value: '30D' }, + { key: '90D', label: '90D', value: '90D' }, + { key: 'ALL', label: 'ALL', value: 'ALL' }, + ]; + + // Get network image for selected network with fallback + const selectedNetworkImg = getNetworkImg(selectedNetwork); + // Get network names + const baseNetworkName = getNetworkName(SupportedNetworks.Base); + const mainnetNetworkName = getNetworkName(SupportedNetworks.Mainnet); + + return ( +
+
+

Platform Statistics

+
+ {/* Network selector */} + + + + + + setSelectedNetwork(SupportedNetworks.Base)} + startContent={ + getNetworkImg(SupportedNetworks.Base) && ( + {baseNetworkName + ) + } + className="py-2" + > + {baseNetworkName} + + setSelectedNetwork(SupportedNetworks.Mainnet)} + startContent={ + getNetworkImg(SupportedNetworks.Mainnet) && ( + {mainnetNetworkName + ) + } + className="py-2" + > + {mainnetNetworkName} + + + + + {/* Timeframe selector */} + setTimeframe(value as TimeFrame)} + size="sm" + variant="default" + /> +
+
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ + + + {/* Transaction Filters */} +
+ {/* Loan Asset Filter */} + + + + + + {uniqueLoanAssets.map((asset) => { + const assetKey = asset.networks.map((n) => `${n.address}-${n.chain.id}`).join('|'); + const firstNetwork = asset.networks[0]; + + return ( + { + if (checked) { + setSelectedLoanAssets([...selectedLoanAssets, assetKey]); + } else { + setSelectedLoanAssets(selectedLoanAssets.filter((k) => k !== assetKey)); + } + }} + className="py-2" + startContent={ + + } + > + {asset.symbol} + + ); + })} + + + + {/* Side Filter */} + + + + + + { + if (checked) { + setSelectedSides([...selectedSides, 'Supply']); + } else { + setSelectedSides(selectedSides.filter((s) => s !== 'Supply')); + } + }} + className="py-2" + > + Supply + + { + if (checked) { + setSelectedSides([...selectedSides, 'Withdraw']); + } else { + setSelectedSides(selectedSides.filter((s) => s !== 'Withdraw')); + } + }} + className="py-2" + > + Withdraw + + + +
+ + +
+ )} +
+ ); +} diff --git a/app/api/monarch/attribution/scoreboard/route.ts b/app/api/monarch/attribution/scoreboard/route.ts new file mode 100644 index 00000000..73fdbee1 --- /dev/null +++ b/app/api/monarch/attribution/scoreboard/route.ts @@ -0,0 +1,41 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { MONARCH_API_KEY, getMonarchUrl } from '../../utils'; +import { reportApiRouteError } from '@/utils/sentry-server'; + +export async function GET(req: NextRequest) { + if (!MONARCH_API_KEY) { + console.error('[Monarch Attribution API] Missing MONARCH_API_KEY'); + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); + } + + const searchParams = req.nextUrl.searchParams; + + try { + const url = getMonarchUrl('/v1/attribution/scoreboard'); + for (const key of ['start_ts', 'end_ts', 'chain_id']) { + const value = searchParams.get(key); + if (value) url.searchParams.set(key, value); + } + + const response = await fetch(url, { + headers: { 'X-API-Key': MONARCH_API_KEY }, + cache: 'no-store', + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Monarch Attribution API] Scoreboard error:', response.status, errorText); + return NextResponse.json({ error: 'Failed to fetch attribution scoreboard' }, { status: response.status }); + } + + return NextResponse.json(await response.json()); + } catch (error) { + reportApiRouteError(error, { + route: '/api/monarch/attribution/scoreboard', + method: 'GET', + status: 500, + }); + console.error('[Monarch Attribution API] Failed to fetch scoreboard:', error); + return NextResponse.json({ error: 'Failed to fetch attribution scoreboard' }, { status: 500 }); + } +} diff --git a/app/api/monarch/attribution/touchpoint/route.ts b/app/api/monarch/attribution/touchpoint/route.ts new file mode 100644 index 00000000..aaf2476b --- /dev/null +++ b/app/api/monarch/attribution/touchpoint/route.ts @@ -0,0 +1,41 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { MONARCH_API_KEY, getMonarchUrl } from '../../utils'; +import { reportApiRouteError } from '@/utils/sentry-server'; + +export async function POST(req: NextRequest) { + if (!MONARCH_API_KEY) { + console.error('[Monarch Attribution API] Missing MONARCH_API_KEY'); + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }); + } + + try { + const body = await req.json(); + const url = getMonarchUrl('/v1/attribution/touchpoint'); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-API-Key': MONARCH_API_KEY, + }, + body: JSON.stringify(body), + cache: 'no-store', + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Monarch Attribution API] Touchpoint error:', response.status, errorText); + return NextResponse.json({ error: 'Failed to save attribution touchpoint' }, { status: response.status }); + } + + return NextResponse.json(await response.json()); + } catch (error) { + reportApiRouteError(error, { + route: '/api/monarch/attribution/touchpoint', + method: 'POST', + status: 500, + }); + console.error('[Monarch Attribution API] Failed to save touchpoint:', error); + return NextResponse.json({ error: 'Failed to save attribution touchpoint' }, { status: 500 }); + } +} diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 00000000..aba562ed Binary files /dev/null and b/public/logo.png differ diff --git a/public/posts/2026-03-06-leverage/erc4626-route.png b/public/posts/2026-03-06-leverage/erc4626-route.png index 4cb2703b..04d0d81d 100644 Binary files a/public/posts/2026-03-06-leverage/erc4626-route.png and b/public/posts/2026-03-06-leverage/erc4626-route.png differ diff --git a/public/posts/2026-03-06-leverage/leverage-overview.png b/public/posts/2026-03-06-leverage/leverage-overview.png index 5a697668..542d901b 100644 Binary files a/public/posts/2026-03-06-leverage/leverage-overview.png and b/public/posts/2026-03-06-leverage/leverage-overview.png differ diff --git a/src/OnchainProviders.tsx b/src/OnchainProviders.tsx index 1b5abbb0..6371844b 100644 --- a/src/OnchainProviders.tsx +++ b/src/OnchainProviders.tsx @@ -7,6 +7,7 @@ import * as Sentry from '@sentry/nextjs'; import { useConnection, WagmiProvider } from 'wagmi'; import { wagmiAdapter } from '@/config/appkit'; import { createWagmiConfig } from '@/store/createWagmiConfig'; +import { AttributionProvider } from './components/providers/AttributionProvider'; import { ConnectRedirectProvider } from './components/providers/ConnectRedirectProvider'; import { CustomRpcProvider, useCustomRpcContext } from './components/providers/CustomRpcProvider'; @@ -46,7 +47,9 @@ function WagmiConfigProvider({ children }: Props) { reconnectOnMount > - {children} + + {children} + ); } diff --git a/src/components/layout/header/Navbar.tsx b/src/components/layout/header/Navbar.tsx index cf2e9970..3540ad2b 100644 --- a/src/components/layout/header/Navbar.tsx +++ b/src/components/layout/header/Navbar.tsx @@ -15,7 +15,6 @@ import { useConnection } from 'wagmi'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useModal } from '@/hooks/useModal'; import { EXTERNAL_LINKS } from '@/utils/external'; -import logo from '../../imgs/logo.png'; import AccountConnect from './AccountConnect'; import { TransactionIndicator } from './TransactionIndicator'; @@ -58,8 +57,9 @@ export function NavbarTitle() { return (
logo ): Promise { + await fetch('/api/monarch/attribution/touchpoint', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); +} + +export function AttributionProvider({ children }: AttributionProviderProps) { + const pathname = usePathname(); + + const touchpoint = useAttributionStore((state) => state.touchpoint); + const lastSubmittedWallet = useAttributionStore((state) => state.lastSubmittedWallet); + const lastSubmittedAt = useAttributionStore((state) => state.lastSubmittedAt); + const captureFromUrl = useAttributionStore((state) => state.captureFromUrl); + const markSubmittedWallet = useAttributionStore((state) => state.markSubmittedWallet); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + captureFromUrl(params, pathname); + }, [captureFromUrl, pathname]); + + useConnectionEffect({ + onConnect: ({ address, chainId, isReconnected }) => { + const normalizedWallet = address.toLowerCase(); + const submittedRecently = + lastSubmittedWallet === normalizedWallet && + typeof lastSubmittedAt === 'number' && + Date.now() - lastSubmittedAt < RESUBMIT_WINDOW_MS; + + if (submittedRecently) { + return; + } + + void submitTouchpoint({ + walletAddress: normalizedWallet, + chainId, + refCode: touchpoint?.refCode ?? undefined, + utmSource: touchpoint?.utmSource ?? undefined, + utmMedium: touchpoint?.utmMedium ?? undefined, + utmCampaign: touchpoint?.utmCampaign ?? undefined, + utmContent: touchpoint?.utmContent ?? undefined, + landingPath: touchpoint?.landingPath ?? pathname, + metadata: { + isReconnected, + }, + }) + .then(() => { + markSubmittedWallet(normalizedWallet); + }) + .catch(() => { + // Keep silent; failures are surfaced in backend telemetry and retried on next connect. + }); + }, + }); + + return <>{children}; +} diff --git a/src/features/admin-v2/components/stats-attribution-overview.tsx b/src/features/admin-v2/components/stats-attribution-overview.tsx new file mode 100644 index 00000000..d115574b --- /dev/null +++ b/src/features/admin-v2/components/stats-attribution-overview.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { Card, CardBody } from '@/components/ui/card'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { formatReadable } from '@/utils/balance'; +import type { AttributionScoreboardRow, AttributionScoreboardSummary } from '@/hooks/useAttributionScoreboard'; + +type StatsAttributionOverviewProps = { + summary: AttributionScoreboardSummary; + breakdown: AttributionScoreboardRow[]; + revenueBps: number; + isLoading: boolean; +}; + +type MiniStatCardProps = { + title: string; + value: string; + subtitle?: string; +}; + +function MiniStatCard({ title, value, subtitle }: MiniStatCardProps) { + return ( + + +

{title}

+

{value}

+ {subtitle &&

{subtitle}

} +
+
+ ); +} + +function formatPercent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function formatPaybackDays(value: number | null): string { + if (value === null || !Number.isFinite(value)) return 'N/A'; + return `${value.toFixed(1)}d`; +} + +export function StatsAttributionOverview({ summary, breakdown, revenueBps, isLoading }: StatsAttributionOverviewProps) { + return ( +
+
+ + + + +
+ +
+
+

Attribution Breakdown

+

Source/medium/campaign cohorts with activation and economics.

+
+
+ {isLoading ? ( +
Loading attribution data...
+ ) : breakdown.length === 0 ? ( +
No attribution cohorts in selected window
+ ) : ( + + + + Source + Medium + Campaign + Ref Code + Leads + Activated + Activation Rate + Volume USD + Revenue USD + Payback + + + + {breakdown.map((row) => ( + + {row.source} + {row.medium} + {row.campaign} + {row.refCode} + {row.qualifiedLeads.toLocaleString()} + {row.activatedAccounts.toLocaleString()} + {formatPercent(row.activationRate)} + ${formatReadable(row.attributedVolumeUsd)} + ${formatReadable(row.attributedRevenueUsd)} + {formatPaybackDays(row.cacPaybackDays)} + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/src/hooks/useAttributionScoreboard.ts b/src/hooks/useAttributionScoreboard.ts new file mode 100644 index 00000000..27c38a88 --- /dev/null +++ b/src/hooks/useAttributionScoreboard.ts @@ -0,0 +1,91 @@ +import { useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import type { TimeFrame } from '@/hooks/useMonarchTransactions'; + +const TIMEFRAME_TO_SECONDS: Record = { + '1D': 24 * 60 * 60, + '7D': 7 * 24 * 60 * 60, + '30D': 30 * 24 * 60 * 60, + '90D': 90 * 24 * 60 * 60, + ALL: 365 * 24 * 60 * 60, +}; + +export type AttributionScoreboardSummary = { + qualifiedLeads: number; + activatedAccounts: number; + activationRate: number; + attributedVolumeUsd: number; + attributedRevenueUsd: number; + distributionCostUsd: number; + cacPaybackDays: number | null; +}; + +export type AttributionScoreboardRow = AttributionScoreboardSummary & { + source: string; + medium: string; + campaign: string; + refCode: string; +}; + +export type AttributionScoreboardResponse = { + startTimestamp: number; + endTimestamp: number; + windowDays: number; + revenueBps: number; + summary: AttributionScoreboardSummary; + breakdown: AttributionScoreboardRow[]; +}; + +function getTimeRange(timeframe: TimeFrame): { startTimestamp: number; endTimestamp: number } { + const now = Math.floor(Date.now() / 1000); + return { + startTimestamp: now - TIMEFRAME_TO_SECONDS[timeframe], + endTimestamp: now, + }; +} + +async function fetchAttributionScoreboard(timeframe: TimeFrame): Promise { + const { startTimestamp, endTimestamp } = getTimeRange(timeframe); + const searchParams = new URLSearchParams({ + start_ts: String(startTimestamp), + end_ts: String(endTimestamp), + }); + + const response = await fetch(`/api/monarch/attribution/scoreboard?${searchParams.toString()}`); + + if (!response.ok) { + throw new Error('Failed to fetch attribution scoreboard'); + } + + return response.json(); +} + +export function useAttributionScoreboard(timeframe: TimeFrame) { + const query = useQuery({ + queryKey: ['attribution-scoreboard', timeframe], + queryFn: () => fetchAttributionScoreboard(timeframe), + staleTime: 2 * 60 * 1000, + refetchInterval: 5 * 60 * 1000, + refetchOnWindowFocus: false, + }); + + const summary = useMemo(() => { + return ( + query.data?.summary ?? { + qualifiedLeads: 0, + activatedAccounts: 0, + activationRate: 0, + attributedVolumeUsd: 0, + attributedRevenueUsd: 0, + distributionCostUsd: 0, + cacPaybackDays: null, + } + ); + }, [query.data?.summary]); + + return { + ...query, + summary, + breakdown: query.data?.breakdown ?? [], + }; +} diff --git a/src/stores/useAttributionStore.ts b/src/stores/useAttributionStore.ts new file mode 100644 index 00000000..20a54506 --- /dev/null +++ b/src/stores/useAttributionStore.ts @@ -0,0 +1,90 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export type AttributionTouchpoint = { + refCode: string | null; + utmSource: string | null; + utmMedium: string | null; + utmCampaign: string | null; + utmContent: string | null; + landingPath: string | null; + capturedAt: number; +}; + +type AttributionState = { + touchpoint: AttributionTouchpoint | null; + lastSubmittedWallet: string | null; + lastSubmittedAt: number | null; +}; + +type AttributionActions = { + captureFromUrl: (params: Pick, pathname: string) => void; + markSubmittedWallet: (walletAddress: string) => void; +}; + +type AttributionStore = AttributionState & AttributionActions; + +function toNullable(value: string | null | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +/** + * Persist first-touch attribution parameters so they survive route changes + * until a wallet connection and first action can be attributed. + */ +export const useAttributionStore = create()( + persist( + (set) => ({ + touchpoint: null, + lastSubmittedWallet: null, + lastSubmittedAt: null, + + captureFromUrl: (params, pathname) => + set((state) => { + const candidate: AttributionTouchpoint = { + refCode: toNullable(params.get('ref_code')), + utmSource: toNullable(params.get('utm_source')), + utmMedium: toNullable(params.get('utm_medium')), + utmCampaign: toNullable(params.get('utm_campaign')), + utmContent: toNullable(params.get('utm_content')), + landingPath: toNullable(pathname), + capturedAt: Date.now(), + }; + + const hasAttributionValues = Boolean( + candidate.refCode ?? candidate.utmSource ?? candidate.utmMedium ?? candidate.utmCampaign ?? candidate.utmContent, + ); + + if (!hasAttributionValues && state.touchpoint) { + return state; + } + + if (!state.touchpoint) { + return { touchpoint: candidate }; + } + + // Preserve first-touch semantics by only filling empty fields. + return { + touchpoint: { + refCode: state.touchpoint.refCode ?? candidate.refCode, + utmSource: state.touchpoint.utmSource ?? candidate.utmSource, + utmMedium: state.touchpoint.utmMedium ?? candidate.utmMedium, + utmCampaign: state.touchpoint.utmCampaign ?? candidate.utmCampaign, + utmContent: state.touchpoint.utmContent ?? candidate.utmContent, + landingPath: state.touchpoint.landingPath ?? candidate.landingPath, + capturedAt: state.touchpoint.capturedAt, + }, + }; + }), + + markSubmittedWallet: (walletAddress) => + set({ + lastSubmittedWallet: walletAddress.toLowerCase(), + lastSubmittedAt: Date.now(), + }), + }), + { name: 'monarch_store_attribution' }, + ), +);