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 000000000..3f1198aa8 Binary files /dev/null and b/e2e/screenshots/baseline/realunit-kpi.spec.ts-realunit-kpi-section-chromium-darwin.png differ diff --git a/src/__tests__/kpi-funnel-chart.test.tsx b/src/__tests__/kpi-funnel-chart.test.tsx new file mode 100644 index 000000000..b072b0288 --- /dev/null +++ b/src/__tests__/kpi-funnel-chart.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import { ApexOptions } from 'apexcharts'; + +// Capture props passed to the chart for assertions +let lastChartProps: { options: ApexOptions; series: { name: string; data: number[] }[] } | undefined; + +jest.mock('react-apexcharts', () => ({ + __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..9218aaf68 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,16 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El .finally(() => setTransactionsLoading(false)); }, [transactions.length]); + const fetchStats = useCallback(() => { + getStats() + .then((statsData) => { + setStats(statsData); + }) + .catch(() => { + setStats(undefined); + }); + }, [setStats]); + const context = useMemo( () => ({ accountSummary, @@ -154,6 +167,8 @@ export function RealunitContextProvider({ children }: PropsWithChildren): JSX.El transactions, quotesLoading, transactionsLoading, + stats, + fetchStats, fetchAccountSummary, fetchAccountHistory, fetchHolders, @@ -180,6 +195,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",