From d04b47f2a7bb0bfeaa2a50c2258d5a52787e2d90 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:50:23 +0200 Subject: [PATCH 1/2] feat(dashboard): add RealUnit KPI/funnel section to /realunit Add a new "Key Figures" section at the top of the RealUnit dashboard, fed by GET /v1/realunit/admin/stats. - dto: RealunitStats* interfaces mirroring the api DTO - hook: getStats() via useApi().call() - context: stats state + fetchStats() callback - screen: Key Figures section (summary cards + KYC funnel chart) as the first section, triggered in the existing data-loading effect - component: KpiFunnelChart (ApexCharts bar chart, DFX blue palette) - i18n: German overrides for the new labels and funnel step names - tests: unit tests for getStats, fetchStats, the chart and the section (100% coverage of the new chart component) - e2e: self-contained Playwright visual spec with mocked stats endpoint and session JWT, plus generated chromium-darwin baseline Companion api PR in DFXswiss/api. --- e2e/realunit-kpi.spec.ts | 169 ++++++++++++++++++ ...s-realunit-kpi-section-chromium-darwin.png | Bin 0 -> 27401 bytes src/__tests__/kpi-funnel-chart.test.tsx | 63 +++++++ src/__tests__/realunit-api.hook.test.ts | 34 ++++ src/__tests__/realunit.context.test.tsx | 48 +++++ src/__tests__/realunit.screen.test.tsx | 123 +++++++++++++ src/components/realunit/kpi-funnel-chart.tsx | 84 +++++++++ src/contexts/realunit.context.tsx | 13 ++ src/dto/realunit.dto.ts | 21 +++ src/hooks/realunit-api.hook.ts | 9 + src/screens/realunit.screen.tsx | 58 +++++- src/test-fixtures/realunit-stats.fixture.ts | 27 +++ src/translations/languages/de.json | 17 +- 13 files changed, 660 insertions(+), 6 deletions(-) create mode 100644 e2e/realunit-kpi.spec.ts create mode 100644 e2e/screenshots/baseline/realunit-kpi.spec.ts-realunit-kpi-section-chromium-darwin.png create mode 100644 src/__tests__/kpi-funnel-chart.test.tsx create mode 100644 src/__tests__/realunit-api.hook.test.ts create mode 100644 src/__tests__/realunit.context.test.tsx create mode 100644 src/__tests__/realunit.screen.test.tsx create mode 100644 src/components/realunit/kpi-funnel-chart.tsx create mode 100644 src/test-fixtures/realunit-stats.fixture.ts diff --git a/e2e/realunit-kpi.spec.ts b/e2e/realunit-kpi.spec.ts new file mode 100644 index 000000000..4e8799b85 --- /dev/null +++ b/e2e/realunit-kpi.spec.ts @@ -0,0 +1,169 @@ +import { expect, Page, test } from '@playwright/test'; + +// Self-contained spec: no live API required. +// - The app derives the user role purely from the (client-side decoded) session JWT, so an +// unsigned but well-formed JWT with role "Admin" lets useRealunitGuard (ADMIN/REALUNIT) pass. +// - All realunit endpoints are intercepted with fixed fixtures to keep the screenshot deterministic. + +function base64url(input: string): string { + return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function buildAdminJwt(): string { + const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payload = base64url( + JSON.stringify({ + id: 1, + address: '0x0000000000000000000000000000000000000001', + role: 'Admin', + blockchains: ['Ethereum'], + // far-future expiry so isExpired() is false + exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 365, + iat: Math.floor(Date.now() / 1000), + }), + ); + // Signature is never verified client-side. + const signature = base64url('test-signature'); + return `${header}.${payload}.${signature}`; +} + +const period = (total: number, last30Days: number, last7Days: number) => ({ total, last30Days, last7Days }); + +const statsFixture = { + updated: '2024-01-15T10:00:00.000Z', + growth: { + accounts: period(1200, 150, 40), + wallets: period(1800, 220, 60), + }, + kycFunnel: [ + { step: 'ContactData', reached: period(1000, 120, 30), completed: period(900, 110, 28) }, + { step: 'PersonalData', reached: period(800, 100, 25), completed: period(700, 90, 22) }, + { step: 'Ident', reached: period(600, 80, 20), completed: period(500, 70, 18) }, + ], + registration: { + started: period(1000, 130, 35), + inReview: period(120, 20, 5), + completed: period(820, 95, 24), + }, + trading: { + buyVolumeChf: period(500000, 60000, 15000), + buyCount: period(300, 40, 10), + sellVolumeChf: period(200000, 25000, 6000), + sellCount: period(150, 18, 5), + }, +}; + +const holdersFixture = { + holders: [ + { address: '0x1111111111111111111111111111111111111111', balance: '1000', percentage: 25.5 }, + { address: '0x2222222222222222222222222222222222222222', balance: '500', percentage: 12.75 }, + { address: '0x3333333333333333333333333333333333333333', balance: '250', percentage: 6.25 }, + ], + pageInfo: { endCursor: '', hasNextPage: false, hasPreviousPage: false, startCursor: '' }, + totalCount: 3, +}; + +const tokenInfoFixture = { + totalShares: { total: '4000', timestamp: '2024-01-15T10:00:00.000Z', txHash: '0xabc' }, + totalSupply: { value: '4000', timestamp: '2024-01-15T10:00:00.000Z' }, +}; + +const priceHistoryFixture = [ + { timestamp: '2024-01-01T00:00:00.000Z', chf: 1.0, eur: 1.05, usd: 1.1 }, + { timestamp: '2024-01-15T00:00:00.000Z', chf: 1.2, eur: 1.25, usd: 1.3 }, +]; + +async function fulfillJson( + route: { fulfill: (r: { status: number; contentType: string; body: string }) => Promise }, + body: unknown, +) { + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); +} + +const languagesFixture = [ + { id: 1, symbol: 'DE', name: 'German', foreignName: 'Deutsch', enable: true }, + { id: 2, symbol: 'EN', name: 'English', foreignName: 'English', enable: true }, + { id: 3, symbol: 'FR', name: 'French', foreignName: 'Français', enable: true }, + { id: 4, symbol: 'IT', name: 'Italian', foreignName: 'Italiano', enable: true }, +]; + +const fiatsFixture = [ + { id: 1, name: 'CHF', sell: true, buy: true, enable: true }, + { id: 2, name: 'EUR', sell: true, buy: true, enable: true }, + { id: 3, name: 'USD', sell: true, buy: true, enable: true }, +]; + +const userFixture = { + accountId: 1, + activeAddress: { + address: '0x0000000000000000000000000000000000000001', + blockchains: ['Ethereum'], + wallet: 'DFX', + isCustomer: false, + }, + addresses: [], + mail: undefined, + language: { id: 1, symbol: 'DE', name: 'German', foreignName: 'Deutsch', enable: true }, + currency: { id: 1, name: 'CHF', sell: true, buy: true, enable: true }, + kyc: { level: 0, hash: '', dataComplete: false }, + tradingLimit: { limit: 0, period: 'Day' }, + status: 'Active', +}; + +async function mockRealunitApi(page: Page): Promise { + // Catch-all FIRST (lowest priority): any unmatched API call returns an empty object so a missing + // local backend can never 401 and trigger a session logout / guard redirect. Specific routes below + // are registered later and therefore take precedence. + await page.route('**/localhost:3000/**', (route) => fulfillJson(route, {})); + + // RealUnit endpoints used by the screen + await page.route('**/realunit/admin/stats', (route) => fulfillJson(route, statsFixture)); + await page.route('**/realunit/holders**', (route) => fulfillJson(route, holdersFixture)); + await page.route('**/realunit/tokenInfo', (route) => fulfillJson(route, tokenInfoFixture)); + await page.route('**/realunit/price/history**', (route) => fulfillJson(route, priceHistoryFixture)); + await page.route('**/realunit/price', (route) => fulfillJson(route, priceHistoryFixture[1])); + await page.route('**/realunit/admin/quotes**', (route) => fulfillJson(route, [])); + await page.route('**/realunit/admin/transactions**', (route) => fulfillJson(route, [])); + + // Session/user bootstrap so useRealunitGuard accepts the session and no unhandled 401 occurs + await page.route('**/v2/user', (route) => fulfillJson(route, userFixture)); + await page.route('**/v2/user/**', (route) => fulfillJson(route, userFixture)); + await page.route(/\/user(\?|$)/, (route) => fulfillJson(route, userFixture)); + await page.route('**/auth/**', (route) => fulfillJson(route, {})); + + // SettingsContext needs a real language list (it reads default.symbol) and currencies. + await page.route('**/language', (route) => fulfillJson(route, languagesFixture)); + await page.route('**/fiat', (route) => fulfillJson(route, fiatsFixture)); + + // Remaining base bootstrap lists -> empty to avoid live API. + for (const path of ['**/asset**', '**/country**', '**/statistic**']) { + await page.route(path, (route) => fulfillJson(route, [])); + } +} + +test.describe('RealUnit KPI / funnel section', () => { + test('renders the Key Figures section with summary cards and funnel chart', async ({ page }) => { + await mockRealunitApi(page); + + const token = buildAdminJwt(); + await page.goto(`/realunit?session=${token}`); + await page.waitForLoadState('networkidle'); + + // Wait for the new section heading to appear. The mocked session resolves the German locale, + // so the heading renders as "Kennzahlen"; accept the English source string too for robustness. + const heading = page.getByRole('heading', { name: /Kennzahlen|Key Figures/ }); + await expect(heading).toBeVisible({ timeout: 15000 }); + + // The funnel chart (ApexCharts svg) should render. + await expect(page.locator('.apexcharts-canvas').first()).toBeVisible({ timeout: 15000 }); + + // Give the chart animation a moment to settle, then snapshot the section. + await page.waitForTimeout(1500); + + const section = page.locator('div.mb-6').filter({ has: heading }).first(); + await expect(section).toHaveScreenshot('realunit-kpi-section.png', { + maxDiffPixelRatio: 0.02, + animations: 'disabled', + }); + }); +}); diff --git a/e2e/screenshots/baseline/realunit-kpi.spec.ts-realunit-kpi-section-chromium-darwin.png b/e2e/screenshots/baseline/realunit-kpi.spec.ts-realunit-kpi-section-chromium-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..3f1198aa89f3ae424ef5fcd8eaa9bb734bd78dd9 GIT binary patch literal 27401 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 ({ + __esModule: true, + default: (props: { options: ApexOptions; series: { name: string; data: number[] }[] }) => { + lastChartProps = { options: props.options, series: props.series }; + return
; + }, +})); + +jest.mock('../contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (_scope: string, key: string) => key, + }), +})); + +import { KpiFunnelChart } from '../components/realunit/kpi-funnel-chart'; +import { realunitStatsFixture } from '../test-fixtures/realunit-stats.fixture'; + +describe('KpiFunnelChart', () => { + beforeEach(() => { + lastChartProps = undefined; + }); + + it('should render the chart container with the title', () => { + render(); + + expect(screen.getByTestId('apex-chart')).toBeInTheDocument(); + expect(screen.getByText('KYC funnel')).toBeInTheDocument(); + }); + + it('should map funnel reached totals to the series data', () => { + render(); + + expect(lastChartProps?.series[0].data).toEqual([1000, 800, 600]); + }); + + it('should map funnel steps to the x-axis categories', () => { + render(); + + expect(lastChartProps?.options.xaxis?.categories).toEqual(['ContactData', 'PersonalData', 'Ident']); + }); + + it('should format tooltip y values with thousands separators', () => { + render(); + + const tooltip = lastChartProps?.options.tooltip; + const formatter = (tooltip?.y as { formatter: (value: number) => string }).formatter; + expect(formatter(1000)).toBe((1000).toLocaleString()); + }); + + it('should fall back to a y-axis max of 1 when the funnel is empty', () => { + const emptyStats = { ...realunitStatsFixture, kycFunnel: [] }; + render(); + + expect(lastChartProps?.options.yaxis).toMatchObject({ max: 1 }); + expect(lastChartProps?.series[0].data).toEqual([]); + }); +}); diff --git a/src/__tests__/realunit-api.hook.test.ts b/src/__tests__/realunit-api.hook.test.ts new file mode 100644 index 000000000..a15847453 --- /dev/null +++ b/src/__tests__/realunit-api.hook.test.ts @@ -0,0 +1,34 @@ +import { renderHook } from '@testing-library/react'; + +const mockCall = jest.fn(); + +jest.mock('@dfx.swiss/react', () => ({ + useApi: () => ({ call: mockCall }), +})); + +import { useRealunitApi } from '../hooks/realunit-api.hook'; +import { realunitStatsFixture } from '../test-fixtures/realunit-stats.fixture'; + +describe('useRealunitApi - getStats', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call the stats endpoint with GET', async () => { + mockCall.mockResolvedValueOnce(realunitStatsFixture); + + const { result } = renderHook(() => useRealunitApi()); + const stats = await result.current.getStats(); + + expect(mockCall).toHaveBeenCalledWith({ url: 'realunit/admin/stats', method: 'GET' }); + expect(stats).toEqual(realunitStatsFixture); + }); + + it('should propagate errors from the api', async () => { + mockCall.mockRejectedValueOnce(new Error('boom')); + + const { result } = renderHook(() => useRealunitApi()); + + await expect(result.current.getStats()).rejects.toThrow('boom'); + }); +}); diff --git a/src/__tests__/realunit.context.test.tsx b/src/__tests__/realunit.context.test.tsx new file mode 100644 index 000000000..4d55c3182 --- /dev/null +++ b/src/__tests__/realunit.context.test.tsx @@ -0,0 +1,48 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { PropsWithChildren } from 'react'; + +const mockGetStats = jest.fn(); + +jest.mock('../hooks/realunit-api.hook', () => ({ + useRealunitApi: () => ({ + getAccountSummary: jest.fn(), + getAccountHistory: jest.fn(), + getHolders: jest.fn(), + getPriceHistory: jest.fn(), + getTokenInfo: jest.fn(), + getTokenPrice: jest.fn(), + getAdminQuotes: jest.fn(), + getAdminTransactions: jest.fn(), + confirmPayment: jest.fn(), + getStats: mockGetStats, + }), +})); + +import { RealunitContextProvider, useRealunitContext } from '../contexts/realunit.context'; +import { realunitStatsFixture } from '../test-fixtures/realunit-stats.fixture'; + +const wrapper = ({ children }: PropsWithChildren) => {children}; + +describe('RealunitContext - fetchStats', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should expose undefined stats initially', () => { + const { result } = renderHook(() => useRealunitContext(), { wrapper }); + expect(result.current.stats).toBeUndefined(); + }); + + it('should populate stats after fetchStats resolves', async () => { + mockGetStats.mockResolvedValueOnce(realunitStatsFixture); + + const { result } = renderHook(() => useRealunitContext(), { wrapper }); + + act(() => { + result.current.fetchStats(); + }); + + await waitFor(() => expect(result.current.stats).toEqual(realunitStatsFixture)); + expect(mockGetStats).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/realunit.screen.test.tsx b/src/__tests__/realunit.screen.test.tsx new file mode 100644 index 000000000..7c2a34240 --- /dev/null +++ b/src/__tests__/realunit.screen.test.tsx @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; +import { RealunitStats } from 'src/dto/realunit.dto'; +import { realunitStatsFixture } from '../test-fixtures/realunit-stats.fixture'; + +const mockContext: { + holders: unknown[]; + totalCount: number; + tokenInfo: unknown; + isLoading: boolean; + priceHistory: unknown[]; + timeframe: string; + quotes: unknown[]; + transactions: unknown[]; + quotesLoading: boolean; + transactionsLoading: boolean; + stats?: RealunitStats; + fetchStats: jest.Mock; + fetchHolders: jest.Mock; + fetchPriceHistory: jest.Mock; + fetchTokenInfo: jest.Mock; + fetchQuotes: jest.Mock; + fetchTransactions: jest.Mock; +} = { + holders: [{ address: '0xabc', balance: '10', percentage: 1 }], + totalCount: 1, + tokenInfo: undefined, + isLoading: false, + priceHistory: [], + timeframe: 'all', + quotes: [], + transactions: [], + quotesLoading: false, + transactionsLoading: false, + stats: realunitStatsFixture, + fetchStats: jest.fn(), + fetchHolders: jest.fn(), + fetchPriceHistory: jest.fn(), + fetchTokenInfo: jest.fn(), + fetchQuotes: jest.fn(), + fetchTransactions: jest.fn(), +}; + +jest.mock('../contexts/realunit.context', () => ({ + useRealunitContext: () => mockContext, +})); + +jest.mock('../contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (_scope: string, key: string) => key, + }), +})); + +jest.mock('../hooks/guard.hook', () => ({ useRealunitGuard: jest.fn() })); +jest.mock('../hooks/layout-config.hook', () => ({ useLayoutOptions: jest.fn() })); +jest.mock('../hooks/navigation.hook', () => ({ useNavigation: () => ({ navigate: jest.fn() }) })); +jest.mock('../hooks/clipboard.hook', () => ({ useClipboard: () => ({ copy: jest.fn() }) })); + +jest.mock('../components/realunit/price-history-chart', () => ({ + PriceHistoryChart: () =>
, +})); +jest.mock('../components/realunit/kpi-funnel-chart', () => ({ + KpiFunnelChart: () =>
, +})); + +jest.mock('@dfx.swiss/react-components', () => ({ + CopyButton: () => , + IconColor: { GRAY: 'gray' }, + SpinnerSize: { SM: 'sm', MD: 'md', LG: 'lg' }, + StyledButton: ({ label }: { label: string }) => , + StyledButtonColor: { STURDY_WHITE: 'sturdy-white' }, + StyledButtonWidth: { FULL: 'full' }, + StyledLoadingSpinner: () =>
, +})); + +// utils transitively imports @dfx.swiss/react (ESM); stub the helpers the screen uses +jest.mock('../util/utils', () => ({ + blankedAddress: (address: string) => address, + formatChf: (value: number) => value.toLocaleString('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }), +})); + +import RealunitScreen from '../screens/realunit.screen'; + +describe('RealunitScreen - Key Figures section', () => { + it('should render the Key Figures heading and the funnel chart', () => { + render(); + + expect(screen.getByText('Key Figures')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-funnel-chart')).toBeInTheDocument(); + }); + + it('should render the summary card labels', () => { + render(); + + expect(screen.getByText('Completed registrations')).toBeInTheDocument(); + expect(screen.getByText('KYC conversion')).toBeInTheDocument(); + }); + + it('should compute the KYC conversion rate (completed Ident / reached ContactData)', () => { + render(); + + // fixture: Ident completed.total = 500, ContactData reached.total = 1000 -> 50.0% + expect(screen.getByText('50.0%')).toBeInTheDocument(); + }); + + it('should show new accounts (30d) value from the fixture', () => { + render(); + + // fixture: growth.accounts.last30Days = 150 + expect(screen.getByText('150')).toBeInTheDocument(); + }); + + it('should show a loading spinner while stats are undefined', () => { + const original = mockContext.stats; + mockContext.stats = undefined; + try { + render(); + expect(mockContext.fetchStats).toHaveBeenCalled(); + expect(screen.getAllByTestId('spinner').length).toBeGreaterThan(0); + } finally { + mockContext.stats = original; + } + }); +}); diff --git a/src/components/realunit/kpi-funnel-chart.tsx b/src/components/realunit/kpi-funnel-chart.tsx new file mode 100644 index 000000000..48c55e947 --- /dev/null +++ b/src/components/realunit/kpi-funnel-chart.tsx @@ -0,0 +1,84 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { RealunitStats } from 'src/dto/realunit.dto'; + +interface KpiFunnelChartProps { + stats: RealunitStats; +} + +export const KpiFunnelChart = ({ stats }: KpiFunnelChartProps): JSX.Element => { + const { translate } = useSettingsContext(); + + const maxReached = useMemo( + () => Math.max(...stats.kycFunnel.map((entry) => entry.reached.total), 0), + [stats.kycFunnel], + ); + + const chartOptions = useMemo((): ApexOptions => { + return { + theme: { + monochrome: { + color: '#092f62', + enabled: true, + }, + }, + chart: { + type: 'bar' as const, + dropShadow: { enabled: false }, + toolbar: { show: false }, + zoom: { enabled: false }, + background: '0', + }, + plotOptions: { + bar: { + horizontal: false, + borderRadius: 4, + columnWidth: '55%', + distributed: true, + }, + }, + legend: { show: false }, + dataLabels: { enabled: false }, + grid: { show: false }, + fill: { + colors: ['#5A81BB'], + }, + xaxis: { + categories: stats.kycFunnel.map((entry) => translate('screens/realunit', entry.step)), + axisBorder: { show: false }, + axisTicks: { show: false }, + labels: { + style: { colors: '#092f62' }, + }, + }, + yaxis: { + show: false, + min: 0, + max: maxReached * 1.2 || 1, + }, + tooltip: { + y: { + formatter: (value: number) => value.toLocaleString(), + }, + }, + }; + }, [stats.kycFunnel, maxReached, translate]); + + const chartSeries = useMemo(() => { + return [ + { + name: translate('screens/realunit', 'KYC funnel'), + data: stats.kycFunnel.map((entry) => entry.reached.total), + }, + ]; + }, [stats.kycFunnel, translate]); + + return ( +
+

{translate('screens/realunit', 'KYC funnel')}

+ +
+ ); +}; diff --git a/src/contexts/realunit.context.tsx b/src/contexts/realunit.context.tsx index 4252871ed..3e4c54627 100644 --- a/src/contexts/realunit.context.tsx +++ b/src/contexts/realunit.context.tsx @@ -9,6 +9,7 @@ import { RealUnitQuote, RealUnitTransaction, RealunitContextInterface, + RealunitStats, TokenInfo, TokenPrice, } from 'src/dto/realunit.dto'; @@ -41,6 +42,7 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El const [transactions, setTransactions] = useState([]); const [quotesLoading, setQuotesLoading] = useState(false); const [transactionsLoading, setTransactionsLoading] = useState(false); + const [stats, setStats] = useState(); const { getAccountSummary, @@ -52,6 +54,7 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El getAdminQuotes, getAdminTransactions, confirmPayment, + getStats, } = useRealunitApi(); const fetchAccountSummary = useCallback( @@ -138,6 +141,12 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El .finally(() => setTransactionsLoading(false)); }, [transactions.length]); + const fetchStats = useCallback(() => { + getStats().then((statsData) => { + setStats(statsData); + }); + }, [setStats]); + const context = useMemo( () => ({ accountSummary, @@ -154,6 +163,8 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El transactions, quotesLoading, transactionsLoading, + stats, + fetchStats, fetchAccountSummary, fetchAccountHistory, fetchHolders, @@ -180,6 +191,8 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El transactions, quotesLoading, transactionsLoading, + stats, + fetchStats, fetchAccountSummary, fetchAccountHistory, fetchHolders, diff --git a/src/dto/realunit.dto.ts b/src/dto/realunit.dto.ts index 9bae24d43..ccb3bfa8a 100644 --- a/src/dto/realunit.dto.ts +++ b/src/dto/realunit.dto.ts @@ -116,6 +116,25 @@ export interface RealUnitTransaction { userAddress?: string; } +export interface RealunitStatsPeriod { + total: number; + last30Days: number; + last7Days: number; +} + +export interface RealunitStats { + updated: string; + growth: { accounts: RealunitStatsPeriod; wallets: RealunitStatsPeriod }; + kycFunnel: { step: string; reached: RealunitStatsPeriod; completed: RealunitStatsPeriod }[]; + registration: { started: RealunitStatsPeriod; inReview: RealunitStatsPeriod; completed: RealunitStatsPeriod }; + trading: { + buyVolumeChf: RealunitStatsPeriod; + buyCount: RealunitStatsPeriod; + sellVolumeChf: RealunitStatsPeriod; + sellCount: RealunitStatsPeriod; + }; +} + export interface RealunitContextInterface { accountSummary?: AccountSummary; history?: AccountHistory; @@ -131,6 +150,8 @@ export interface RealunitContextInterface { transactions: RealUnitTransaction[]; quotesLoading: boolean; transactionsLoading: boolean; + stats?: RealunitStats; + fetchStats: () => void; fetchAccountSummary: (address: string) => void; fetchAccountHistory: (address: string, cursor?: string, direction?: PaginationDirection) => void; fetchHolders: (cursor?: string, direction?: PaginationDirection) => void; diff --git a/src/hooks/realunit-api.hook.ts b/src/hooks/realunit-api.hook.ts index bbc2b51f1..3ff300d0a 100644 --- a/src/hooks/realunit-api.hook.ts +++ b/src/hooks/realunit-api.hook.ts @@ -8,6 +8,7 @@ import { PriceHistoryEntry, RealUnitQuote, RealUnitTransaction, + RealunitStats, TokenInfo, TokenPrice, } from 'src/dto/realunit.dto'; @@ -101,6 +102,13 @@ export function useRealunitApi() { }); } + async function getStats(): Promise { + return call({ + url: 'realunit/admin/stats', + method: 'GET', + }); + } + return useMemo( () => ({ getAccountSummary, @@ -112,6 +120,7 @@ export function useRealunitApi() { getAdminQuotes, getAdminTransactions, confirmPayment, + getStats, }), [call], ); diff --git a/src/screens/realunit.screen.tsx b/src/screens/realunit.screen.tsx index 95b278988..44bcb86c5 100644 --- a/src/screens/realunit.screen.tsx +++ b/src/screens/realunit.screen.tsx @@ -8,6 +8,8 @@ import { StyledLoadingSpinner, } from '@dfx.swiss/react-components'; import { useEffect } from 'react'; +import { SummaryCard } from 'src/components/dashboard/summary-card'; +import { KpiFunnelChart } from 'src/components/realunit/kpi-funnel-chart'; import { PriceHistoryChart } from 'src/components/realunit/price-history-chart'; import { useRealunitContext } from 'src/contexts/realunit.context'; import { useSettingsContext } from 'src/contexts/settings.context'; @@ -15,7 +17,7 @@ import { useClipboard } from 'src/hooks/clipboard.hook'; import { useRealunitGuard } from 'src/hooks/guard.hook'; import { useLayoutOptions } from 'src/hooks/layout-config.hook'; import { useNavigation } from 'src/hooks/navigation.hook'; -import { blankedAddress } from 'src/util/utils'; +import { blankedAddress, formatChf } from 'src/util/utils'; export default function RealunitScreen(): JSX.Element { useRealunitGuard(); @@ -34,6 +36,8 @@ export default function RealunitScreen(): JSX.Element { transactions, quotesLoading, transactionsLoading, + stats, + fetchStats, fetchHolders, fetchPriceHistory, fetchTokenInfo, @@ -49,12 +53,24 @@ export default function RealunitScreen(): JSX.Element { if (!priceHistory.length) fetchPriceHistory(); if (!quotes.length) fetchQuotes(); if (!transactions.length) fetchTransactions(); - }, [fetchHolders, fetchTokenInfo, fetchQuotes, fetchTransactions]); + if (!stats) fetchStats(); + }, [fetchHolders, fetchTokenInfo, fetchQuotes, fetchTransactions, fetchStats]); const topHolders = holders.slice(0, 3); const topQuotes = quotes.slice(0, 3); const topTransactions = transactions.slice(0, 3); + const kycConversionRate = (): string => { + if (!stats) return '-'; + const reachedContactData = stats.kycFunnel.find((entry) => entry.step === 'ContactData')?.reached.total ?? 0; + const completedIdent = stats.kycFunnel.find((entry) => entry.step === 'Ident')?.completed.total ?? 0; + if (!reachedContactData) return '-'; + return `${((completedIdent / reachedContactData) * 100).toFixed(1)}%`; + }; + + const tradingVolumeChf30Days = (): number => + stats ? stats.trading.buyVolumeChf.last30Days + stats.trading.sellVolumeChf.last30Days : 0; + const displayType = (type: string): string => { switch (type) { case 'BuyFiat': @@ -77,6 +93,40 @@ export default function RealunitScreen(): JSX.Element { ) : (
+
+

{translate('screens/realunit', 'Key Figures')}

+ {stats ? ( +
+
+ + + + +
+ +
+ ) : ( +
+ +
+ )} +
+

{translate('screens/realunit', 'Price History')}

navigate(`/realunit/transactions/${tx.id}`)} > {displayType(tx.type)} - - {tx.amountInChf?.toLocaleString()} - + {tx.amountInChf?.toLocaleString()} {tx.userAddress ? blankedAddress(tx.userAddress, { displayLength: 12 }) : '-'} diff --git a/src/test-fixtures/realunit-stats.fixture.ts b/src/test-fixtures/realunit-stats.fixture.ts new file mode 100644 index 000000000..311ebb064 --- /dev/null +++ b/src/test-fixtures/realunit-stats.fixture.ts @@ -0,0 +1,27 @@ +import { RealunitStats } from 'src/dto/realunit.dto'; + +const period = (total: number, last30Days: number, last7Days: number) => ({ total, last30Days, last7Days }); + +export const realunitStatsFixture: RealunitStats = { + updated: '2024-01-15T10:00:00.000Z', + growth: { + accounts: period(1200, 150, 40), + wallets: period(1800, 220, 60), + }, + kycFunnel: [ + { step: 'ContactData', reached: period(1000, 120, 30), completed: period(900, 110, 28) }, + { step: 'PersonalData', reached: period(800, 100, 25), completed: period(700, 90, 22) }, + { step: 'Ident', reached: period(600, 80, 20), completed: period(500, 70, 18) }, + ], + registration: { + started: period(1000, 130, 35), + inReview: period(120, 20, 5), + completed: period(820, 95, 24), + }, + trading: { + buyVolumeChf: period(500000, 60000, 15000), + buyCount: period(300, 40, 10), + sellVolumeChf: period(200000, 25000, 6000), + sellCount: period(150, 18, 5), + }, +}; diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index afb97c7b4..aa599bfc2 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -1100,7 +1100,22 @@ "Date": "Datum", "Confirm Payment Received": "Zahlungseingang bestätigen", "Are you sure you want to confirm the payment receipt?": "Möchten Sie den Zahlungseingang wirklich bestätigen?", - "Payment confirmed successfully": "Zahlungseingang erfolgreich bestätigt" + "Payment confirmed successfully": "Zahlungseingang erfolgreich bestätigt", + "Key Figures": "Kennzahlen", + "New accounts": "Neue Konten", + "Completed registrations": "Abgeschlossene Registrierungen", + "KYC conversion": "KYC-Conversion", + "Trading volume": "Handelsvolumen", + "Buy volume": "Kaufvolumen", + "Sell volume": "Verkaufsvolumen", + "KYC funnel": "KYC-Funnel", + "Last 30 days": "Letzte 30 Tage", + "Last 7 days": "Letzte 7 Tage", + "ContactData": "Kontaktdaten", + "PersonalData": "Persönliche Daten", + "NationalityData": "Nationalität", + "Ident": "Identifikation", + "FinancialData": "Finanzdaten" }, "screens/blockchain": { "Transaction signing": "Transaktionssignierung", From 1c88b89a9d26d465111c78b6e78651745ea0a2c2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:07:32 +0200 Subject: [PATCH 2/2] fix(dashboard): handle fetchStats rejection in realunit context --- src/contexts/realunit.context.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/contexts/realunit.context.tsx b/src/contexts/realunit.context.tsx index 3e4c54627..9218aaf68 100644 --- a/src/contexts/realunit.context.tsx +++ b/src/contexts/realunit.context.tsx @@ -142,9 +142,13 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El }, [transactions.length]); const fetchStats = useCallback(() => { - getStats().then((statsData) => { - setStats(statsData); - }); + getStats() + .then((statsData) => { + setStats(statsData); + }) + .catch(() => { + setStats(undefined); + }); }, [setStats]); const context = useMemo(