diff --git a/.gitignore b/.gitignore index 39ff73012..32d72a778 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ test-results/ # production /build /widget +/partner-dist /dist # misc diff --git a/e2e-stack/specs/partner-dashboard.spec.ts b/e2e-stack/specs/partner-dashboard.spec.ts new file mode 100644 index 000000000..c4fb36dac --- /dev/null +++ b/e2e-stack/specs/partner-dashboard.spec.ts @@ -0,0 +1,32 @@ +/** + * Partner dashboard e2e — owns: + * /partner/dashboard + * + * Not covered (by design / environment limits): + * - Positive case (role NonCustodialWalletPartner sees the dashboard): the API does not yet + * grant that role (DFXswiss/api#4587 unmerged). Covered instead by the mocked Handbook e2e + * (e2e/partner-dashboard.spec.ts). + */ + +import { expect, gotoWithSession, normPath, test } from './fixtures'; +import { cleanupCreatedData, createUser } from './fixtures/factories'; + +test.describe('Partner dashboard e2e', () => { + test.afterAll(async () => { + await cleanupCreatedData(); + }); + + test('plain User role is denied /partner/dashboard', async ({ page }) => { + const user = await createUser({ tag: 'partner-dash-guard', language: 'EN' }); + + await gotoWithSession(page, '/partner/dashboard', user.jwt); + await page.waitForLoadState('networkidle'); + + await expect + .poll(() => normPath(new URL(page.url()).pathname), { + message: 'User must be redirected away from /partner/dashboard', + timeout: 15000, + }) + .not.toBe(normPath('/partner/dashboard')); + }); +}); diff --git a/e2e-stack/specs/registry/partner.ts b/e2e-stack/specs/registry/partner.ts new file mode 100644 index 000000000..a39df0ab7 --- /dev/null +++ b/e2e-stack/specs/registry/partner.ts @@ -0,0 +1,5 @@ +import type { RouteClaim } from './types'; + +const claims: RouteClaim[] = [{ path: '/partner/dashboard', spec: 'partner-dashboard.spec.ts' }]; + +export default claims; diff --git a/e2e/partner-dashboard.spec.ts b/e2e/partner-dashboard.spec.ts new file mode 100644 index 000000000..67b38602e --- /dev/null +++ b/e2e/partner-dashboard.spec.ts @@ -0,0 +1,515 @@ +import { expect, Page, Route, test } from '@playwright/test'; + +/** + * E2E Visual Regression Tests: Partner Dashboard (`/partner/dashboard`) + * + * Auth is a synthetic JWT with role `NonCustodialWalletPartner` (client-side guard only; + * jwtDecode without signature check). All API traffic is mocked so baselines stay + * deterministic and do not depend on DEV API role grants. + * + * Fixed clock: 2026-06-30T12:00:00.000Z — period controls and chart axis labels derive + * ranges from `new Date()`, so a moving wall clock would invalidate baselines. + * + * Four product states: + * - dashboard-light — filled metrics, light theme (default) + * - dashboard-dark — same data, dark theme via localStorage pre-seed + * - dashboard-error — statistic endpoint HTTP 500 → ErrorState + * - dashboard-empty — zero totals + empty timeline buckets → EmptyState + */ + +const FIXED_NOW = '2026-06-30T12:00:00.000Z'; +/** 30-day window ending at FIXED_NOW (matches App.periodRange(30) under the frozen clock). */ +const PERIOD = { + from: '2026-06-01T00:00:00.000Z', + to: FIXED_NOW, +} as const; + +const PARTNER_THEME_STORAGE_KEY = 'partner-dashboard-theme'; + +const LANGUAGES = [ + { id: 1, name: 'English', symbol: 'EN', foreignName: 'English', enable: true }, + { id: 2, name: 'German', symbol: 'DE', foreignName: 'Deutsch', enable: true }, + { id: 3, name: 'French', symbol: 'FR', foreignName: 'Français', enable: true }, + { id: 4, name: 'Italian', symbol: 'IT', foreignName: 'Italiano', enable: true }, +]; + +const LANGUAGE_EN = LANGUAGES[0]; + +const FIAT_CHF = { + id: 1, + name: 'Swiss Franc', + buyable: true, + sellable: true, + cardBuyable: false, + cardSellable: false, + instantBuyable: false, + instantSellable: false, +}; + +/** + * Constant filled statistic body — values fixed; not derived from Date.now(). + * + * All-time figures are deliberately invented round numbers (same order of magnitude + * as a busy partner, not production partner data). The presentation fixture under + * src/partner-dashboard/fixtures/ documents real production-checked values for unit + * tests; handbook screenshots must not republish those figures. + */ +const FILLED_STATISTIC = { + period: { from: PERIOD.from, to: PERIOD.to }, + currency: 'CHF' as const, + totals: { + volume: { buy: 180000, sell: 24000, swap: 6000, total: 210000 }, + transactions: { buy: 1500, sell: 300, swap: 100, total: 1900 }, + averageTransactionVolume: 110.5, + activeUsers: 1200, + newUsers: 200, + }, + allTime: { + volume: { buy: 9_000_000, sell: 1_000_000, total: 10_000_000 }, + registeredUsers: 100_000, + tradingUsers: 20_000, + }, + breakdown: { + assets: [ + { name: 'BTC', blockchain: 'Bitcoin', direction: 'Buy', volume: 80000, transactions: 500 }, + { name: 'ETH', blockchain: 'Ethereum', direction: 'Buy', volume: 45000, transactions: 350 }, + { name: 'USDT', blockchain: 'Ethereum', direction: 'Buy', volume: 25000, transactions: 250 }, + { name: 'XMR', blockchain: 'Monero', direction: 'Buy', volume: 15000, transactions: 150 }, + { name: 'BTC', blockchain: 'Bitcoin', direction: 'Sell', volume: 12000, transactions: 120 }, + { name: 'LTC', blockchain: 'Litecoin', direction: 'Buy', volume: 8000, transactions: 80 }, + ], + fiatCurrencies: [ + { name: 'CHF', volume: 120000, transactions: 1000 }, + { name: 'EUR', volume: 70000, transactions: 700 }, + { name: 'USD', volume: 0, transactions: 0 }, + ], + blockchains: [ + { name: 'Bitcoin', volume: 92000, transactions: 620 }, + { name: 'Ethereum', volume: 70000, transactions: 600 }, + { name: 'Monero', volume: 15000, transactions: 150 }, + { name: 'Litecoin', volume: 8000, transactions: 80 }, + ], + paymentMethods: [ + { name: 'Bank', volume: 140000, transactions: 1200 }, + { name: 'Card', volume: 0, transactions: 0 }, + { name: 'OnChain', volume: 35000, transactions: 300 }, + ], + }, + referral: { + volume: 40000, + creditEarned: 1200, + creditPaid: 900, + creditOpen: 300, + currency: 'EUR' as const, + }, + meta: { generatedAt: '2026-07-01T08:00:00.000Z' }, +}; + +/** + * Fixed 10-bucket day series spanning the frozen period (deterministic axis labels). + * Edge buckets marked partial per API contract; one real-zero mid bucket retained. + */ +const FILLED_TIMELINE = { + period: { from: PERIOD.from, to: PERIOD.to }, + currency: 'CHF' as const, + granularity: 'Day' as const, + buckets: [ + { date: '2026-06-01T00:00:00.000Z', volume: { buy: 6200, sell: 710, swap: 240 }, transactions: { buy: 56, sell: 10, swap: 3 }, partial: true }, + { date: '2026-06-04T00:00:00.000Z', volume: { buy: 7100, sell: 820, swap: 310 }, transactions: { buy: 65, sell: 12, swap: 3 }, partial: false }, + { date: '2026-06-07T00:00:00.000Z', volume: { buy: 5800, sell: 640, swap: 180 }, transactions: { buy: 53, sell: 9, swap: 2 }, partial: false }, + { date: '2026-06-10T00:00:00.000Z', volume: { buy: 8400, sell: 910, swap: 420 }, transactions: { buy: 76, sell: 13, swap: 5 }, partial: false }, + { date: '2026-06-13T00:00:00.000Z', volume: { buy: 0, sell: 0, swap: 0 }, transactions: { buy: 0, sell: 0, swap: 0 }, partial: false }, + { date: '2026-06-16T00:00:00.000Z', volume: { buy: 9200, sell: 1050, swap: 380 }, transactions: { buy: 84, sell: 15, swap: 4 }, partial: false }, + { date: '2026-06-19T00:00:00.000Z', volume: { buy: 7800, sell: 880, swap: 290 }, transactions: { buy: 71, sell: 13, swap: 3 }, partial: false }, + { date: '2026-06-22T00:00:00.000Z', volume: { buy: 6500, sell: 720, swap: 210 }, transactions: { buy: 59, sell: 10, swap: 2 }, partial: false }, + { date: '2026-06-25T00:00:00.000Z', volume: { buy: 10100, sell: 1120, swap: 450 }, transactions: { buy: 92, sell: 16, swap: 5 }, partial: false }, + { date: '2026-06-30T00:00:00.000Z', volume: { buy: 8700, sell: 940, swap: 330 }, transactions: { buy: 79, sell: 13, swap: 4 }, partial: true }, + ], + meta: { generatedAt: '2026-07-01T08:00:00.000Z' }, +}; + +/** Zero-activity period: KPIs at 0, empty timeline → EmptyState in charts and bar lists. */ +const EMPTY_STATISTIC = { + period: { from: PERIOD.from, to: PERIOD.to }, + currency: 'CHF' as const, + totals: { + volume: { buy: 0, sell: 0, swap: 0, total: 0 }, + transactions: { buy: 0, sell: 0, swap: 0, total: 0 }, + averageTransactionVolume: null, + activeUsers: 0, + newUsers: 0, + }, + allTime: { + volume: { buy: 0, sell: 0, total: 0 }, + registeredUsers: 0, + tradingUsers: 0, + }, + breakdown: { + assets: [], + fiatCurrencies: [], + blockchains: [], + paymentMethods: [], + }, + referral: { + volume: 0, + creditEarned: 0, + creditPaid: 0, + creditOpen: 0, + currency: 'EUR' as const, + }, + meta: { generatedAt: '2026-07-01T08:00:00.000Z' }, +}; + +const EMPTY_TIMELINE = { + period: { from: PERIOD.from, to: PERIOD.to }, + currency: 'CHF' as const, + granularity: 'Day' as const, + buckets: [] as typeof FILLED_TIMELINE.buckets, + meta: { generatedAt: '2026-07-01T08:00:00.000Z' }, +}; + +const USER_V2 = { + accountId: 1, + accountType: 'Personal', + mail: 'partner@example.com', + language: LANGUAGE_EN, + currency: FIAT_CHF, + tradingLimit: { limit: 0, period: 'Year' }, + kyc: { hash: 'synthetic-hash', level: 50, dataComplete: true, preferredPhoneTimes: [] }, + volumes: { buy: 0, sell: 0, swap: 0 }, + addresses: [], + disabledAddresses: [], + activeAddress: { + wallet: 'Mail', + address: 'partner@example.com', + blockchains: [], + volumes: { buy: 0, sell: 0, swap: 0 }, + isCustody: false, + }, + paymentLink: { active: false }, + apiKeyCT: '', + apiFilterCT: [], +}; + +type PartnerMockMode = 'filled' | 'empty' | 'error'; + +function partnerJwt(): string { + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + // Fixed far-future exp so isExpired() stays false under the frozen clock. + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode({ + account: 1, + user: 1, + role: 'NonCustodialWalletPartner', + exp: 4102444800, // 2100-01-01T00:00:00.000Z + })}.synthetic`; +} + +async function fulfillJson(route: Route, body: unknown, status = 200): Promise { + await route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify(body), + }); +} + +/** + * Intercept every API call. A synthetic JWT is rejected by DEV with 401, and any 401 + * clears the session — so nothing may fall through to the real API. + */ +async function installPartnerApi(page: Page, mode: PartnerMockMode): Promise { + await page.route('**/v1/**', async (route: Route) => { + const request = route.request(); + if (request.method() !== 'GET') { + await fulfillJson(route, {}); + return; + } + + const path = new URL(request.url()).pathname; + + if (path === '/v1/statistic/partner/timeline') { + if (mode === 'error') { + await fulfillJson(route, { statusCode: 500, message: 'Internal server error' }, 500); + return; + } + await fulfillJson(route, mode === 'empty' ? EMPTY_TIMELINE : FILLED_TIMELINE); + return; + } + + if (path === '/v1/statistic/partner') { + if (mode === 'error') { + await fulfillJson(route, { statusCode: 500, message: 'Internal server error' }, 500); + return; + } + await fulfillJson(route, mode === 'empty' ? EMPTY_STATISTIC : FILLED_STATISTIC); + return; + } + + if (path === '/v1/language') { + await fulfillJson(route, LANGUAGES); + return; + } + + if (path === '/v1/fiat') { + await fulfillJson(route, [FIAT_CHF]); + return; + } + + if (path === '/v1/country' || path === '/v1/asset' || path === '/v1/bankAccount') { + await fulfillJson(route, []); + return; + } + + if (path === '/v1/setting/infoBanner') { + await fulfillJson(route, null); + return; + } + + // Statistic paths must be explicitly handled — a rename must fail the test loudly, + // not return an empty array that crashes the UI with a confusing shape error. + if (path.startsWith('/v1/statistic/')) { + await fulfillJson( + route, + { statusCode: 501, message: `Unmocked statistic path in e2e: ${path}` }, + 501, + ); + return; + } + + // Harmless default for any other bootstrap GET the shell may fire. + await fulfillJson(route, []); + }); + + await page.route('**/v2/**', async (route: Route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + + if (request.method() === 'GET' && path === '/v2/user') { + await fulfillJson(route, USER_V2); + return; + } + + await fulfillJson(route, {}); + }); +} + +async function freezeTime(page: Page): Promise { + await page.clock.setFixedTime(new Date(FIXED_NOW)); +} + +async function seedTheme(page: Page, theme: 'light' | 'dark'): Promise { + await page.addInitScript( + ({ key, value }) => { + try { + window.localStorage.setItem(key, value); + } catch { + // ignore + } + }, + { key: PARTNER_THEME_STORAGE_KEY, value: theme }, + ); +} + +function dashboardUrl(token: string): string { + // Force English so KPI/chart copy stays stable regardless of browser locale. + return `/partner/dashboard?session=${token}&lang=en`; +} + +/** + * Wait until ApexCharts has painted series paths and the SVG markup stops changing. + * Chart animations are disabled in product code, but layout still settles asynchronously. + * Stability is sampled on consecutive animation frames (paint end-signal), not a fixed sleep. + */ +async function waitChartsSettled(page: Page): Promise { + await expect(page.locator('.apexcharts-canvas').first()).toBeVisible({ timeout: 30000 }); + await expect(page.locator('.apexcharts-series path, .apexcharts-area-series path').first()).toBeVisible({ + timeout: 15000, + }); + + await page.locator('.apexcharts-svg').first().evaluate(async (el) => { + let prev = ''; + for (let i = 0; i < 60; i++) { + const current = el.innerHTML; + if (current.length > 200 && current === prev) return; + prev = current; + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + } + }); +} + +/** + * Layout scrolls inside `.overflow-auto` under `#app-root`. A fixed tall viewport + + * fullPage stitches sticky/fixed chrome twice on short pages (error/empty showed the + * header a second time near y≈1880). Grow the viewport to the measured content height + * and capture a single viewport frame instead — no stitch, no duplicate header, no + * trailing empty band. + * + * Product CSS forces fill-height (html/body/#root 100%, `.partner-dashboard` min-height + * 100vh) so short states paint a large empty theme surface. That is real product chrome, + * but a handbook baseline must not end in empty canvas — so for the shot only we collapse + * those min-heights via an injected stylesheet (not a src/** change), measure, resize the + * viewport to content, and leave the override in place for the screenshot. + */ +async function fitViewportToContent(page: Page): Promise { + await page.addStyleTag({ + content: ` + html, body, body > div, #app-root { + height: auto !important; + min-height: 0 !important; + overflow: visible !important; + } + #app-root .overflow-auto, + #app-root .flex-grow { + height: auto !important; + min-height: 0 !important; + flex-grow: 0 !important; + overflow: visible !important; + } + .partner-dashboard { + min-height: 0 !important; + height: auto !important; + flex: 0 0 auto !important; + } + `, + }); + + // Let the injected rules reflow before measuring. + await page.locator('[data-testid="partner-dashboard-root"]').evaluate((el) => el.getBoundingClientRect().height); + + const height = await page.evaluate(() => { + const root = document.getElementById('app-root'); + if (!root) { + return Math.ceil(document.documentElement.scrollHeight); + } + return Math.ceil( + Math.max(root.scrollHeight, root.getBoundingClientRect().height, document.body.scrollHeight), + ); + }); + + // +1 avoids sub-pixel scrollbar; clamp keeps empty shells tight and filled ones complete. + const next = Math.min(Math.max(height + 1, 200), 4000); + await page.setViewportSize({ width: 1280, height: next }); + // Force a layout pass against the new viewport before the shot. + await page.locator('#app-root').evaluate((el) => el.getBoundingClientRect().height); +} + +const screenshotOpts = { + // Viewport is already fitted to content — fullPage would re-stitch and can reintroduce + // duplicate sticky chrome on short pages. + fullPage: false, + // Charts/anti-aliasing only — content changes must still fail the assertion. + // Stricter than the repo-common 10000; deterministic mocks keep both compare runs under this. + maxDiffPixels: 1500, + timeout: 15000, + animations: 'disabled' as const, +}; + +test.describe('Partner Dashboard - Visual Regression', () => { + // Start at the repo-default desktop size; each test grows the viewport to fit content + // before the shot (see fitViewportToContent). + // timezoneId + locale pin axis labels (toLocaleDateString) independent of host TZ. + test.use({ + viewport: { width: 1280, height: 720 }, + reducedMotion: 'reduce', + timezoneId: 'UTC', + locale: 'en-US', + }); + + const token = partnerJwt(); + + test('redirects away when the session role is not NonCustodialWalletPartner', async ({ page }) => { + // No screenshot — proves the client guard, not just jsdom unit tests. + const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const userToken = `${encode({ alg: 'none', typ: 'JWT' })}.${encode({ + account: 1, + user: 1, + role: 'User', + exp: 4102444800, + })}.synthetic`; + + await freezeTime(page); + await installPartnerApi(page, 'filled'); + await page.goto(`/partner/dashboard?session=${userToken}&lang=en`); + await page.waitForLoadState('domcontentloaded'); + + await expect(page.getByTestId('partner-dashboard-root')).toHaveCount(0, { timeout: 15000 }); + await expect(page).not.toHaveURL(/\/partner\/dashboard/); + }); + + test('dashboard-light — filled data, light theme', async ({ page }) => { + await freezeTime(page); + await seedTheme(page, 'light'); + await installPartnerApi(page, 'filled'); + + await page.goto(dashboardUrl(token)); + await page.waitForLoadState('domcontentloaded'); + + await expect(page.getByTestId('partner-dashboard-root')).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId('partner-dashboard-root')).toHaveAttribute('data-theme', 'light'); + await expect(page.getByTestId('kpi-grid')).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId('kpi-volume')).toBeVisible(); + await expect(page.getByTestId('volume-time-chart')).toBeVisible(); + await waitChartsSettled(page); + + // First non-empty axis label is exactly the period start (UTC + en-US → "Jun 1"). + // Apex nests a so element textContent is sometimes "Jun 1Jun 1"; assert the tspan. + const firstTickLabel = page.locator('.apexcharts-xaxis-texts-g text tspan').filter({ hasText: /\S/ }).first(); + await expect(firstTickLabel).toHaveText(/^Jun 1$/); + + await fitViewportToContent(page); + await expect(page).toHaveScreenshot('dashboard-light.png', screenshotOpts); + }); + + test('dashboard-dark — filled data, dark theme', async ({ page }) => { + await freezeTime(page); + await seedTheme(page, 'dark'); + await installPartnerApi(page, 'filled'); + + await page.goto(dashboardUrl(token)); + await page.waitForLoadState('domcontentloaded'); + + await expect(page.getByTestId('partner-dashboard-root')).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId('partner-dashboard-root')).toHaveAttribute('data-theme', 'dark'); + await expect(page.getByTestId('kpi-grid')).toBeVisible({ timeout: 30000 }); + await waitChartsSettled(page); + + await fitViewportToContent(page); + await expect(page).toHaveScreenshot('dashboard-dark.png', screenshotOpts); + }); + + test('dashboard-error — statistic endpoint 500', async ({ page }) => { + await freezeTime(page); + await seedTheme(page, 'light'); + await installPartnerApi(page, 'error'); + + await page.goto(dashboardUrl(token)); + await page.waitForLoadState('domcontentloaded'); + + await expect(page.getByTestId('partner-dashboard-root')).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId('dashboard-error')).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId('kpi-grid')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible(); + + await fitViewportToContent(page); + await expect(page).toHaveScreenshot('dashboard-error.png', screenshotOpts); + }); + + test('dashboard-empty — zero metrics, empty timeline', async ({ page }) => { + await freezeTime(page); + await seedTheme(page, 'light'); + await installPartnerApi(page, 'empty'); + + await page.goto(dashboardUrl(token)); + await page.waitForLoadState('domcontentloaded'); + + await expect(page.getByTestId('partner-dashboard-root')).toBeVisible({ timeout: 30000 }); + await expect(page.getByTestId('kpi-grid')).toBeVisible({ timeout: 30000 }); + // Charts + bar lists all render EmptyState (data-testid="dashboard-empty"). + await expect(page.getByTestId('dashboard-empty').first()).toBeVisible({ timeout: 15000 }); + await expect(page.getByText('No volume data for the selected period.')).toBeVisible(); + await expect(page.getByText('No transaction data for the selected period.')).toBeVisible(); + await expect(page.locator('.apexcharts-canvas')).toHaveCount(0); + + await fitViewportToContent(page); + await expect(page).toHaveScreenshot('dashboard-empty.png', screenshotOpts); + }); +}); diff --git a/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-dark-chromium-darwin.png b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-dark-chromium-darwin.png new file mode 100644 index 000000000..d990ff9ce Binary files /dev/null and b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-dark-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-empty-chromium-darwin.png b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-empty-chromium-darwin.png new file mode 100644 index 000000000..a9447aa33 Binary files /dev/null and b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-empty-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-error-chromium-darwin.png b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-error-chromium-darwin.png new file mode 100644 index 000000000..484a40699 Binary files /dev/null and b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-error-chromium-darwin.png differ diff --git a/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-light-chromium-darwin.png b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-light-chromium-darwin.png new file mode 100644 index 000000000..f5e792b27 Binary files /dev/null and b/e2e/screenshots/baseline/partner-dashboard.spec.ts-dashboard-light-chromium-darwin.png differ diff --git a/package.json b/package.json index b71795252..be14e4f28 100644 --- a/package.json +++ b/package.json @@ -193,7 +193,8 @@ "node_modules/(?!(@dfx.swiss|@scure|@noble|@solana|bitcoinjs-lib|bitcoinjs-message|tronweb|tweetnacl)/)" ], "testPathIgnorePatterns": [ - "/node_modules/" + "/node_modules/", + "/src/__tests__/helpers/" ], "setupFilesAfterEnv": [ "/src/setupTests.ts" diff --git a/scripts/handbook/metadata.json b/scripts/handbook/metadata.json index 19e41d27b..f6baadd24 100644 --- a/scripts/handbook/metadata.json +++ b/scripts/handbook/metadata.json @@ -139,6 +139,10 @@ "title": "Support-Dashboard Übersicht", "description": "Support-Dashboard-Übersicht und Statistiken." }, + "partner-dashboard": { + "title": "Partner-Dashboard", + "description": "Non-Custodial Partner Program: geladenes Dashboard (Light/Dark), Fehlerzustand und leerer Datenzustand." + }, "subpage": { "title": "Unterseiten", "description": "Buy-, Sell-, Swap- und Transaktions-Unterseiten." diff --git a/src/App.tsx b/src/App.tsx index e984cf6ae..3a7aa7b22 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -81,6 +81,7 @@ const SupportDashboardOverviewScreen = lazy(() => import('./screens/support-dash const SupportDashboardScreen = lazy(() => import('./screens/support-dashboard.screen')); const SupportDashboardIssueScreen = lazy(() => import('./screens/support-dashboard-issue.screen')); const SupportDashboardCreateScreen = lazy(() => import('./screens/support-dashboard-create.screen')); +const PartnerDashboardScreen = lazy(() => import('./screens/partner-dashboard.screen')); const NotesScreen = lazy(() => import('./screens/notes.screen')); const TemplatesScreen = lazy(() => import('./screens/support-templates.screen')); const RealunitScreen = lazy(() => import('./screens/realunit.screen')); @@ -489,6 +490,10 @@ export const Routes = [ path: 'support/dashboard/create', element: withSuspense(), }, + { + path: 'partner/dashboard', + element: withSuspense(), + }, { path: 'notes', element: withSuspense(), diff --git a/src/__tests__/app-routes-coverage.test.tsx b/src/__tests__/app-routes-coverage.test.tsx new file mode 100644 index 000000000..441bb7770 --- /dev/null +++ b/src/__tests__/app-routes-coverage.test.tsx @@ -0,0 +1,754 @@ +// Coverage of App.tsx route table: every lazy() factory, loaders, Suspense fallback path. +// Pattern follows app-widget-rerender.test.tsx — real App + createMemoryRouter, screens mocked. + +import { act, render, screen, waitFor } from '@testing-library/react'; +import { createMemoryRouter, RouteObject } from 'react-router-dom'; +import { Router } from '@remix-run/router'; +import App, { Routes, Service } from '../App'; + +jest.mock('@dfx.swiss/react', () => ({ + DfxContextProvider: ({ children }: any) => children, + PaymentRoutesContextProvider: ({ children }: any) => children, + SupportChatContextProvider: ({ children }: any) => children, +})); + +jest.mock('@dfx.swiss/react-components', () => ({ + SpinnerSize: { SM: 'sm', LG: 'lg' }, + StyledLoadingSpinner: ({ size }: { size?: string }) => ( +
+ ), +})); + +jest.mock('../contexts/window.context', () => ({ + WindowContextProvider: ({ children }: any) => children, + useWindowContext: () => ({}), +})); + +jest.mock('../contexts/balance.context', () => ({ + BalanceContextProvider: ({ children }: any) => children, + useBalanceContext: () => ({ getBalances: () => [], readBalances: () => undefined, hasBalance: false }), +})); + +jest.mock('../contexts/order-ui.context', () => ({ + OrderUIContextProvider: ({ children }: any) => children, + useOrderUIContext: () => ({}), +})); + +jest.mock('../contexts/app-handling.context', () => ({ + AppHandlingContextProvider: ({ children }: any) => children, + useAppHandlingContext: () => ({}), +})); + +jest.mock('../contexts/settings.context', () => ({ + SettingsContextProvider: ({ children }: any) => children, + useSettingsContext: () => ({}), +})); + +jest.mock('../contexts/wallet.context', () => ({ + WalletContextProvider: ({ children }: any) => children, + useWalletContext: () => ({}), +})); + +jest.mock('../components/layout-wrapper', () => ({ + LayoutWrapper: ({ children }: any) =>
{children}
, +})); + +jest.mock('../contexts/payment-link.context', () => ({ + PaymentLinkProvider: ({ children }: any) => children, + usePaymentLinkContext: () => ({}), +})); + +jest.mock('../contexts/payment-link-pos.context', () => ({ + __esModule: true, + default: ({ children }: any) => children, + usePaymentPosContext: () => ({}), +})); + +jest.mock('../contexts/realunit.context', () => ({ + RealunitContextProvider: ({ children }: any) => children, + useRealunitContext: () => ({}), +})); + +jest.mock('../screens/sell.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/swap.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/account.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/settings.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/staff-kyc-required.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/buy-failure.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/buy-info.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/buy-success.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/buy.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/kyc-redirect.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/kyc-file.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/download.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/kyc.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/kyc-log.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/link.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/payment-routes.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/payment-link.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/payment-link-pos.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/payment-link-assign.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/payment-link-result.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/invoice.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/sell-info.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support-issue.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support-tickets.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/chat.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/tfa.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/transaction.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/account-merge.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/mail-login.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/sepa.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/sepa-manual.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/stickers.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/blockchain-tx.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/edit-mail.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/safe.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-bank-tx.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-bank-tx-recall.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-bank-tx-return.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-kyc-files.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-kyc-files-details.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-kyc-stats.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-transaction-list.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-kyc-step.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-support-issue.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-recommendation-graph.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-scorechain.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-custody-orders.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-mros-list.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-mros-create.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-mros-detail.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-recall-list.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-review.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-call-queues.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-call-queue.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-call-queue-detail.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support-dashboard-overview.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support-dashboard.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support-dashboard-issue.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support-dashboard-create.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/partner-dashboard.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/notes.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/support-templates.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-holders.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-quotes.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-transactions.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-quote-detail.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-transaction-detail.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-user.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-support.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-support-issue.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-compliance.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/realunit-compliance-user.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/personal-iban.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/buy-crypto-update.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard-financial.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard-financial-overview.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard-financial-history.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard-financial-live.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard-financial-expenses.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard-financial-liquidity.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/dashboard-financial-log-validity.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/sitemap.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/compliance-user.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/error.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../screens/home.screen', () => ({ + __esModule: true, + default: () =>
, +})); + +const ROUTE_PATHS: string[] = [ + "/", + "/account", + "/account/mail", + "/settings", + "/login", + "/login/mail", + "/login/wallet", + "/connect", + "/mail-login", + "/buy", + "/buy/info", + "/buy/success", + "/buy/failure", + "/buy/personal-iban", + "/sell", + "/sell/info", + "/swap", + "/routes", + "/pl/pos", + "/pl", + "/pl/assign", + "/pl/result", + "/payment-link?foo=1", + "/invoice", + "/kyc", + "/kyc/redirect", + "/profile", + "/contact", + "/link", + "/2fa", + "/staff-kyc-required", + "/file/download", + "/file/abc", + "/kyc/log", + "/buyCrypto/update", + "/tx", + "/tx/tid", + "/tx/tid/assign", + "/tx/tid/refund", + "/support", + "/support/tickets", + "/support/issue", + "/support/chat", + "/support/chat/cid", + "/account-merge", + "/sepa", + "/sepa/manual", + "/stickers", + "/blockchain/tx", + "/safe", + "/recommendation?bar=2", + "/compliance", + "/compliance/user/1", + "/support/user/1", + "/compliance/user/1/kyc-step/s1", + "/compliance/user/1/support-issue/i1", + "/compliance/recommendations/1", + "/compliance/scorechain/user/1", + "/compliance/bank-tx/1", + "/compliance/bank-tx/1/recall", + "/compliance/bank-tx/1/return", + "/compliance/kyc-files", + "/compliance/kyc-files/details", + "/compliance/kyc-stats", + "/compliance/transactions", + "/compliance/custody-orders", + "/compliance/mros", + "/compliance/mros/create", + "/compliance/mros/9", + "/compliance/recalls", + "/compliance/user/1/kyc", + "/compliance/call-queues", + "/compliance/call-queues/q1", + "/compliance/call-queues/q1/42", + "/sitemap", + "/support/dashboard", + "/support/dashboard/all", + "/support/dashboard/issue/1", + "/support/dashboard/create", + "/partner/dashboard", + "/notes", + "/templates", + "/realunit", + "/realunit/holders", + "/realunit/quotes", + "/realunit/quotes/1", + "/realunit/transactions", + "/realunit/transactions/1", + "/realunit/user/0xabc", + "/realunit/support", + "/realunit/support/issue/1", + "/realunit/compliance", + "/realunit/compliance/user/1", + "/dashboard", + "/dashboard/financial", + "/dashboard/financial/overview", + "/dashboard/financial/live", + "/dashboard/financial/history", + "/dashboard/financial/history/expenses", + "/dashboard/financial/liquidity", + "/dashboard/financial/log-validity" +]; + +function createCapturingRouterFactory(initialEntries?: string[]) { + let router: Router | undefined; + const factory = jest.fn((routes: RouteObject[]) => { + router = createMemoryRouter(routes, initialEntries ? { initialEntries } : undefined); + return router as Router; + }); + return { factory, getRouter: () => router as Router }; +} + +describe('App.tsx route table coverage', () => { + it('invokes every lazy factory and both redirect loaders by navigating each path', async () => { + const { factory, getRouter } = createCapturingRouterFactory(['/']); + render(); + const router = getRouter(); + expect(factory).toHaveBeenCalledTimes(1); + // Routes export is the same table the factory received + expect(Routes.length).toBeGreaterThan(0); + + for (const path of ROUTE_PATHS) { + await act(async () => { + await router.navigate(path); + }); + } + + // Invoke redirect loaders via the route objects the factory received + // (same table as Routes export — deterministic, no race with data-router redirects). + type LoaderRoute = { + path?: string; + loader?: (args: { request: Request; params: Record; context: unknown }) => unknown; + children?: LoaderRoute[]; + }; + function findLoader(routes: LoaderRoute[], path: string): NonNullable | undefined { + for (const r of routes) { + if (r.path === path && typeof r.loader === 'function') return r.loader; + if (r.children) { + const found = findLoader(r.children, path); + if (found) return found; + } + } + return undefined; + } + + const factoryRoutes = factory.mock.calls[0][0] as LoaderRoute[]; + const paymentLoader = findLoader(factoryRoutes, 'payment-link') ?? findLoader(Routes as LoaderRoute[], 'payment-link'); + if (!paymentLoader) throw new Error('payment-link loader missing'); + const paymentResult = paymentLoader({ + request: new Request('http://localhost/payment-link?keep=1'), + params: {}, + context: undefined, + }) as Response; + expect(paymentResult).toBeInstanceOf(Response); + expect(paymentResult.headers.get('Location')).toBe('/pl?keep=1'); + + const recLoader = findLoader(factoryRoutes, 'recommendation') ?? findLoader(Routes as LoaderRoute[], 'recommendation'); + if (!recLoader) throw new Error('recommendation loader missing'); + const recResult = recLoader({ + request: new Request('http://localhost/recommendation?keep=2'), + params: {}, + context: undefined, + }) as Response; + expect(recResult).toBeInstanceOf(Response); + expect(recResult.headers.get('Location')).toBe('/account?keep=2'); + + // Layout still mounted after the tour + expect(screen.getByTestId('layout')).toBeInTheDocument(); + }, 60000); + + it('mounts PartnerDashboardScreen only on /partner/dashboard (path is not decorative)', async () => { + // Catches path: 'partner/dashboard' → 'partner/dashboards' which left 1001 tests green. + const { factory, getRouter } = createCapturingRouterFactory(['/partner/dashboard']); + render(); + await waitFor(() => { + expect(screen.getByTestId('screen-partner-dashboard.screen')).toBeInTheDocument(); + }); + expect(getRouter().state.location.pathname).toBe('/partner/dashboard'); + + await act(async () => { + await getRouter().navigate('/partner/dashboards'); + }); + // Wrong path must not still show the partner screen + expect(screen.queryByTestId('screen-partner-dashboard.screen')).not.toBeInTheDocument(); + }); + + it('declares path partner/dashboard on the Routes table (exact string)', () => { + type RouteNode = { path?: string; children?: RouteNode[] }; + function collect(routes: RouteNode[], out: string[] = []): string[] { + for (const r of routes) { + if (r.path) out.push(r.path); + if (r.children) collect(r.children, out); + } + return out; + } + const paths = collect(Routes as RouteNode[]); + expect(paths).toContain('partner/dashboard'); + expect(paths).not.toContain('partner/dashboards'); + }); + + it('navigates to service home when WidgetParams.service is set (BUY)', async () => { + const { factory, getRouter } = createCapturingRouterFactory(); + render(); + await waitFor(() => expect(getRouter().state.location.pathname).toBe('/buy')); + }); + + it('navigates to service home when WidgetParams.service is SELL', async () => { + const { factory, getRouter } = createCapturingRouterFactory(); + render(); + await waitFor(() => expect(getRouter().state.location.pathname).toBe('/sell')); + }); + + it('navigates to service home when WidgetParams.service is SWAP', async () => { + const { factory, getRouter } = createCapturingRouterFactory(); + render(); + await waitFor(() => expect(getRouter().state.location.pathname).toBe('/swap')); + }); + + it('keeps the same router on re-render and does not re-run home navigation', async () => { + const { factory, getRouter } = createCapturingRouterFactory(); + const { rerender } = render( + , + ); + await waitFor(() => expect(getRouter().state.location.pathname).toBe('/connect')); + expect(factory).toHaveBeenCalledTimes(1); + + await act(async () => { + getRouter().navigate('/login'); + }); + await waitFor(() => expect(getRouter().state.location.pathname).toBe('/login')); + + // Re-render hits the false arms of !routerRef.current and !hasNavigatedHomeRef.current + rerender(); + expect(factory).toHaveBeenCalledTimes(1); + await waitFor(() => expect(getRouter().state.location.pathname).toBe('/login')); + }); +}); diff --git a/src/__tests__/helpers/mock-settings-context.ts b/src/__tests__/helpers/mock-settings-context.ts new file mode 100644 index 000000000..e84995516 --- /dev/null +++ b/src/__tests__/helpers/mock-settings-context.ts @@ -0,0 +1,35 @@ +import type { Language } from '@dfx.swiss/react'; + +/** App languages (same symbols as settings.context `appLanguages`). */ +export const PARTNER_TEST_LANGUAGES: Language[] = [ + { id: 1, name: 'Deutsch', symbol: 'DE', foreignName: 'German', enable: true }, + { id: 2, name: 'English', symbol: 'EN', foreignName: 'English', enable: true }, + { id: 3, name: 'Français', symbol: 'FR', foreignName: 'French', enable: true }, + { id: 4, name: 'Italiano', symbol: 'IT', foreignName: 'Italian', enable: true }, +]; + +export const mockChangeLanguage = jest.fn(); + +/** + * Mutable stand-in for `useSettingsContext` in partner dashboard tests. + * Wire with: + * jest.mock('src/contexts/settings.context', () => ({ + * useSettingsContext: () => + * require('./helpers/mock-settings-context').mockSettingsState, + * })); + */ +export const mockSettingsState = { + language: PARTNER_TEST_LANGUAGES[1] as Language | undefined, + availableLanguages: PARTNER_TEST_LANGUAGES as Language[], + changeLanguage: mockChangeLanguage, + translate: (_key: string, defaultValue: string) => defaultValue, +}; + +export function resetMockSettings(): void { + mockChangeLanguage.mockReset(); + mockSettingsState.language = PARTNER_TEST_LANGUAGES[1]; + mockSettingsState.availableLanguages = PARTNER_TEST_LANGUAGES; + mockChangeLanguage.mockImplementation((lang: Language) => { + mockSettingsState.language = lang; + }); +} diff --git a/src/__tests__/navigation-coverage.test.tsx b/src/__tests__/navigation-coverage.test.tsx new file mode 100644 index 000000000..69961e280 --- /dev/null +++ b/src/__tests__/navigation-coverage.test.tsx @@ -0,0 +1,383 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; + +/** + * Full coverage of src/components/navigation.tsx — role gates, back button, + * menu toggle, login/logout, headless/embedded, small mode, custody. + * Pattern matches partner-dashboard-nav.test.tsx (real Navigation, mocked contexts). + */ + +let mockSession: { role: string } | undefined; +let mockIsLoggedIn = true; +let mockHasCustody = false; +let mockIsEmbedded = false; +let mockParams: { headless?: string } = {}; +let mockPathname = '/buy'; +const mockNavigate = jest.fn(); +const mockCloseServices = jest.fn(); +const mockLogout = jest.fn().mockResolvedValue(undefined); + +jest.mock('@dfx.swiss/react', () => ({ + UserRole: { + ADMIN: 'Admin', + USER: 'User', + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + MARKETING: 'Marketing', + REALUNIT: 'RealUnit', + }, + useAuthContext: () => ({ session: mockSession }), + useSessionContext: () => ({ isLoggedIn: mockIsLoggedIn, logout: mockLogout }), + useUserContext: () => ({ hasCustody: mockHasCustody }), +})); + +jest.mock('src/contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: true }), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (_key: string, defaultValue: string) => defaultValue, + }), +})); + +jest.mock('src/contexts/app-handling.context', () => ({ + CloseType: { CANCEL: 'cancel' }, + useAppHandlingContext: () => ({ + params: mockParams, + isEmbedded: mockIsEmbedded, + closeServices: mockCloseServices, + }), +})); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { + ...actual, + useLocation: () => ({ pathname: mockPathname }), + }; +}); + +jest.mock('@dfx.swiss/react-components', () => { + const IconVariant = new Proxy( + {}, + { + get: (_t, prop: string) => prop, + }, + ); + return { + IconVariant, + IconColor: { BLUE: 'blue', RED: 'red' }, + IconSize: { LG: 'lg' }, + DfxIcon: ({ icon }: { icon: string }) => , + StyledButton: ({ + label, + onClick, + hidden, + }: { + label: string; + onClick?: () => void; + hidden?: boolean; + }) => + hidden ? null : ( + + ), + StyledButtonColor: { STURDY_WHITE: 'sturdy-white' }, + StyledButtonWidth: { FULL: 'full' }, + StyledLink: ({ + label, + onClick, + url, + }: { + label: string; + onClick?: () => void; + url?: string; + }) => ( + + {label} + + ), + }; +}); + +jest.mock('src/version', () => ({ + REACT_APP_BUILD_ID: 'test-build-id', +})); + +import { UserRole } from '@dfx.swiss/react'; +import { Navigation } from 'src/components/navigation'; + +function renderNav( + props: Partial> = {}, +): { + setIsOpen: jest.Mock; +} { + const setIsOpen = jest.fn(); + render( + + + , + ); + return { setIsOpen }; +} + +describe('Navigation full coverage', () => { + beforeEach(() => { + mockSession = { role: UserRole.USER }; + mockIsLoggedIn = true; + mockHasCustody = false; + mockIsEmbedded = false; + mockParams = {}; + mockPathname = '/buy'; + mockNavigate.mockReset(); + mockCloseServices.mockReset(); + mockLogout.mockReset().mockResolvedValue(undefined); + }); + + it('renders nothing when embedded without a title', () => { + mockIsEmbedded = true; + const { container } = render( + + + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows title text and skips the logo when title is set', () => { + renderNav({ title: 'Custom Title', isOpen: false }); + expect(screen.getByText('Custom Title')).toBeInTheDocument(); + expect(screen.queryByAltText('logo')).not.toBeInTheDocument(); + }); + + it('applies headless chrome when params.headless is true', () => { + mockParams = { headless: 'true' }; + renderNav({ isOpen: false }); + // Headless: no back button / logo block + expect(screen.queryByTestId('dfx-icon-BACK')).not.toBeInTheDocument(); + expect(screen.queryByAltText('logo')).not.toBeInTheDocument(); + }); + + it('toggles the menu via the menu icon and closes via the overlay', async () => { + const setIsOpen = jest.fn(); + const { rerender } = render( + + + , + ); + + // Menu icon toggles open (prev => !prev) + await userEvent.click(screen.getByTestId('dfx-icon-MENU').parentElement as HTMLElement); + expect(setIsOpen).toHaveBeenCalled(); + const toggleFn = setIsOpen.mock.calls[0][0] as (prev: boolean) => boolean; + expect(toggleFn(false)).toBe(true); + expect(toggleFn(true)).toBe(false); + + setIsOpen.mockClear(); + rerender( + + + , + ); + + // Overlay click closes + const overlay = document.querySelector('.fixed.inset-0.z-40') as HTMLElement; + expect(overlay).toBeTruthy(); + fireEvent.click(overlay); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); + + it('BackButton on root path closes services; elsewhere navigates back', async () => { + mockPathname = '/'; + renderNav({ isOpen: false }); + await userEvent.click(screen.getByTestId('dfx-icon-BACK').parentElement as HTMLElement); + expect(mockCloseServices).toHaveBeenCalledWith({ type: 'cancel' }, false); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('BackButton on a nested path navigates -1', async () => { + mockPathname = '/buy'; + renderNav({ isOpen: false }); + await userEvent.click(screen.getByTestId('dfx-icon-BACK').parentElement as HTMLElement); + expect(mockNavigate).toHaveBeenCalledWith(-1); + expect(mockCloseServices).not.toHaveBeenCalled(); + }); + + it('uses custom onBack when provided', async () => { + const onBack = jest.fn(); + renderNav({ isOpen: false, onBack }); + await userEvent.click(screen.getByTestId('dfx-icon-BACK').parentElement as HTMLElement); + expect(onBack).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockCloseServices).not.toHaveBeenCalled(); + }); + + it('backButton=false hides the back control', () => { + renderNav({ isOpen: false, backButton: false }); + expect(screen.queryByTestId('dfx-icon-BACK')).not.toBeInTheDocument(); + }); + + it('shows Safe when hasCustody is true', () => { + mockHasCustody = true; + renderNav(); + expect(screen.getByText('Safe')).toBeInTheDocument(); + }); + + it('shows Compliance for Admin and Compliance roles', () => { + mockSession = { role: UserRole.ADMIN }; + renderNav(); + expect(screen.getByText('Compliance')).toBeInTheDocument(); + }); + + it('shows Support Dashboard for Support and Marketing roles', () => { + mockSession = { role: UserRole.SUPPORT }; + const { unmount } = render( + + + , + ); + expect(screen.getByText('Support Dashboard')).toBeInTheDocument(); + unmount(); + + mockSession = { role: UserRole.MARKETING }; + renderNav(); + expect(screen.getByText('Support Dashboard')).toBeInTheDocument(); + }); + + it('shows RealUnit for Admin/RealUnit/Compliance and Financial+Sitemap only for Admin', () => { + mockSession = { role: UserRole.REALUNIT }; + const { unmount } = render( + + + , + ); + expect(screen.getByText('RealUnit')).toBeInTheDocument(); + expect(screen.queryByText('Financial')).not.toBeInTheDocument(); + expect(screen.queryByText('Sitemap')).not.toBeInTheDocument(); + unmount(); + + mockSession = { role: UserRole.ADMIN }; + renderNav(); + expect(screen.getByText('RealUnit')).toBeInTheDocument(); + expect(screen.getByText('Financial')).toBeInTheDocument(); + expect(screen.getByText('Sitemap')).toBeInTheDocument(); + }); + + it('shows NC Partner Program only for NonCustodialWalletPartner and closes on click', async () => { + mockSession = { role: 'NonCustodialWalletPartner' }; + const { setIsOpen } = renderNav(); + expect(screen.getByText('NC Partner Program')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('nav-link-NC Partner Program')); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); + + it('small mode hides the main product links but keeps external/support links', () => { + renderNav({ small: true }); + expect(screen.queryByText('Buy')).not.toBeInTheDocument(); + expect(screen.getByText('Support')).toBeInTheDocument(); + expect(screen.getByText('DFX.swiss')).toBeInTheDocument(); + }); + + it('small mode hides the auth button when logged out', () => { + mockIsLoggedIn = false; + renderNav({ small: true }); + expect(screen.queryByTestId('nav-auth-button')).not.toBeInTheDocument(); + }); + + it('login navigates to /login and closes the menu', async () => { + mockIsLoggedIn = false; + const { setIsOpen } = renderNav(); + expect(screen.getByTestId('nav-auth-button')).toHaveTextContent('Login'); + await userEvent.click(screen.getByTestId('nav-auth-button')); + expect(mockNavigate).toHaveBeenCalledWith('/login'); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); + + it('logout calls apiLogout and closes the menu', async () => { + mockIsLoggedIn = true; + const { setIsOpen } = renderNav(); + expect(screen.getByTestId('nav-auth-button')).toHaveTextContent('Logout'); + await userEvent.click(screen.getByTestId('nav-auth-button')); + expect(mockLogout).toHaveBeenCalled(); + await waitForLogoutClose(setIsOpen); + }); + + it('clicking every menu link invokes its onClose (relative + absolute)', async () => { + // Admin + custody + partner role bits so every gated entry is visible + mockSession = { role: UserRole.ADMIN }; + mockHasCustody = true; + const setIsOpen = jest.fn(); + render( + + + , + ); + + const labels = [ + 'Buy', + 'Sell', + 'Swap', + 'Account', + 'Safe', + 'Transactions', + 'KYC', + 'Settings', + 'Compliance', + 'Support Dashboard', + 'RealUnit', + 'Financial', + 'Sitemap', + 'DFX.swiss', + 'Support', + 'Open CryptoPay', + 'Terms and conditions', + 'Privacy policy', + 'Imprint', + ]; + + for (const label of labels) { + setIsOpen.mockClear(); + mockNavigate.mockClear(); + const link = screen.getByTestId(`nav-link-${label}`); + await userEvent.click(link); + expect(setIsOpen).toHaveBeenCalledWith(false); + } + }); + + it('clicking an absolute external link closes the menu', async () => { + const { setIsOpen } = renderNav(); + await userEvent.click(screen.getByTestId('nav-link-Terms and conditions')); + expect(setIsOpen).toHaveBeenCalledWith(false); + }); + + it('stops click propagation on the nav element so the overlay does not close twice', () => { + const { setIsOpen } = renderNav(); + const nav = document.querySelector('nav') as HTMLElement; + const event = new MouseEvent('click', { bubbles: true }); + const stopSpy = jest.spyOn(event, 'stopPropagation'); + nav.dispatchEvent(event); + expect(stopSpy).toHaveBeenCalled(); + // Overlay handler not reached via nav stopPropagation — setIsOpen only from links if any + expect(setIsOpen).not.toHaveBeenCalledWith(false); + }); + + it('renders the build id in the footer', () => { + renderNav(); + expect(screen.getByText('test-build-id')).toBeInTheDocument(); + }); +}); + +async function waitForLogoutClose(setIsOpen: jest.Mock): Promise { + // logout is async — wait a tick for setIsNavigationOpen(false) + await Promise.resolve(); + await Promise.resolve(); + expect(setIsOpen).toHaveBeenCalledWith(false); +} diff --git a/src/__tests__/partner-alltime-metrics-zero-registered.test.tsx b/src/__tests__/partner-alltime-metrics-zero-registered.test.tsx new file mode 100644 index 000000000..5c954e028 --- /dev/null +++ b/src/__tests__/partner-alltime-metrics-zero-registered.test.tsx @@ -0,0 +1,98 @@ +import { render, screen, waitFor, within } from '@testing-library/react'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { formatAmountWhole, formatCount } from 'src/partner-dashboard/util/format'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + // jest hoists this factory; mock-prefixed import is allowed in scope + useSettingsContext: () => mockSettingsState, +})); + +/** + * Render-path proof for the zero-registered guard. + * + * Separate file on purpose: mocking `usePartnerDashboard` here would break the + * real-fixture suite in `partner-alltime-metrics.test.tsx` if co-located. + */ +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +jest.mock('src/hooks/partner-dashboard.hook', () => { + const { buildPartnerStatisticFixture, buildPartnerTimelineFixture } = jest.requireActual( + 'src/partner-dashboard/fixtures/partner-statistic.fixture', + ); + const zeroAllTimeStatistic = { + ...buildPartnerStatisticFixture(), + allTime: { + volume: { buy: 0, sell: 0, total: 0 }, + registeredUsers: 0, + tradingUsers: 0, + }, + }; + return { + usePartnerDashboard: () => ({ + isFixture: true, + getPartnerStatistic: async () => zeroAllTimeStatistic, + getPartnerTimeline: async () => buildPartnerTimelineFixture('Day'), + }), + }; +}); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +describe('partner dashboard all-time metrics with zero registered users', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('fetch must not be called in fixture mode'); + }); + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + fetchSpy.mockRestore(); + }); + + it('shows a not-applicable conversion caption — never NaN, Infinity, or a percent', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('kpi-trading-users')).toBeInTheDocument(); + }); + + const tradingTile = screen.getByTestId('kpi-trading-users'); + + // 1. Trading users is a real zero (formatCount), not the absent placeholder. + const valueEl = within(tradingTile).getByTestId('kpi-value'); + expect(within(tradingTile).queryByTestId('kpi-absent')).not.toBeInTheDocument(); + expect(valueEl).toHaveTextContent(formatCount(0)); + + // 2–4. Caption: visible not-applicable state; no rate formatting, no NaN/Infinity. + // Intentionally does NOT assert the exact prose — that would only prove two literals match. + const caption = screen.getByTestId('kpi-trading-users-caption'); + const captionText = caption.textContent ?? ''; + expect(captionText).not.toMatch(/NaN/i); + expect(captionText).not.toMatch(/Infinity/i); + // Real conversion rates always go through formatPercent → suffix " %". + expect(captionText).not.toMatch(/%/); + expect(captionText.trim().length).toBeGreaterThan(1); + expect(captionText.trim()).not.toBe('–'); + + // 5. Lifetime volume under the same zero scenario stays a real zero, not NaN. + const lifetimeTile = screen.getByTestId('kpi-lifetime-volume'); + expect(lifetimeTile).toHaveTextContent(formatAmountWhole(0, 'CHF')); + const lifetimeCaption = screen.getByTestId('kpi-lifetime-volume-caption'); + const lifetimeCaptionText = lifetimeCaption.textContent ?? ''; + expect(lifetimeCaptionText).not.toMatch(/NaN/i); + expect(lifetimeCaptionText).not.toMatch(/Infinity/i); + expect(lifetimeCaptionText).toContain(formatAmountWhole(0, 'CHF')); + }); +}); diff --git a/src/__tests__/partner-alltime-metrics.test.tsx b/src/__tests__/partner-alltime-metrics.test.tsx new file mode 100644 index 000000000..e6ce6a03d --- /dev/null +++ b/src/__tests__/partner-alltime-metrics.test.tsx @@ -0,0 +1,80 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { + formatAmountWhole, + formatCount, + formatPercent, +} from 'src/partner-dashboard/util/format'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + // jest allows out-of-scope vars prefixed with `mock` inside the factory + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +describe('partner dashboard all-time metrics (trading users + lifetime volume)', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('fetch must not be called in fixture mode'); + }); + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + fetchSpy.mockRestore(); + }); + + it('shows trading users, conversion rate caption, and lifetime volume with buy/sell split', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('kpi-trading-users')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-lifetime-volume')).toBeInTheDocument(); + }); + + // Fixture: tradingUsers=24360, registeredUsers=126547, volume.total=12713029.93 + const tradingCount = formatCount(24360); + const tradingTile = screen.getByTestId('kpi-trading-users'); + expect(tradingTile).toHaveTextContent(tradingCount); + + const tradingCaption = screen.getByTestId('kpi-trading-users-caption'); + expect(tradingCaption).toHaveTextContent('of registered users'); + // ~19.2 % (one decimal); do not hard-code a specific rounding beyond formatPercent + expect(tradingCaption.textContent).toMatch(/19[,.][23]\s*%/); + // Sanity: same as formatPercent on the fixture ratio + const expectedPct = formatPercent(24360 / 126547, 1); + expect(tradingCaption).toHaveTextContent(expectedPct); + + const lifetimeTotal = formatAmountWhole(12_713_029.93, 'CHF'); + const lifetimeTile = screen.getByTestId('kpi-lifetime-volume'); + expect(lifetimeTile).toHaveTextContent(lifetimeTotal); + + const lifetimeCaption = screen.getByTestId('kpi-lifetime-volume-caption'); + expect(lifetimeCaption).toHaveTextContent('Buy'); + expect(lifetimeCaption).toHaveTextContent('Sell'); + expect(lifetimeCaption).toHaveTextContent(formatAmountWhole(11_858_002.52, 'CHF')); + expect(lifetimeCaption).toHaveTextContent(formatAmountWhole(855_027.41, 'CHF')); + + // DOM order: registered → trading users → lifetime volume + const registered = screen.getByTestId('kpi-registered'); + const trading = screen.getByTestId('kpi-trading-users'); + const lifetime = screen.getByTestId('kpi-lifetime-volume'); + + expect(registered.compareDocumentPosition(trading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(trading.compareDocumentPosition(lifetime) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); diff --git a/src/__tests__/partner-app-asset-keys.test.tsx b/src/__tests__/partner-app-asset-keys.test.tsx new file mode 100644 index 000000000..868f82032 --- /dev/null +++ b/src/__tests__/partner-app-asset-keys.test.tsx @@ -0,0 +1,79 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { buildPartnerStatisticFixture, buildPartnerTimelineFixture } from 'src/partner-dashboard/fixtures/partner-statistic.fixture'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +const mockGetPartnerStatistic = jest.fn(); +const mockGetPartnerTimeline = jest.fn(); + +jest.mock('src/hooks/partner-dashboard.hook', () => ({ + usePartnerDashboard: () => ({ + getPartnerStatistic: mockGetPartnerStatistic, + getPartnerTimeline: mockGetPartnerTimeline, + isFixture: false, + }), +})); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +describe('PartnerDashboardView asset row key without blockchain', () => { + beforeEach(() => { + mockGetPartnerStatistic.mockReset(); + mockGetPartnerTimeline.mockReset(); + }); + + it('labels an asset by name alone when blockchain is null (no " (…)" suffix)', async () => { + const base = buildPartnerStatisticFixture(); + mockGetPartnerStatistic.mockResolvedValue({ + ...base, + breakdown: { + ...base.breakdown, + assets: [ + { + name: 'NoChainCoin', + blockchain: null, + direction: 'Buy' as const, + volume: 42, + transactions: 3, + }, + // Second row with same name+null merges into one bar + { + name: 'NoChainCoin', + blockchain: null, + direction: 'Sell' as const, + volume: 8, + transactions: 1, + }, + ], + }, + }); + mockGetPartnerTimeline.mockResolvedValue(buildPartnerTimelineFixture('Day')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('bars-assets')).toBeInTheDocument(); + }); + + const rows = screen.getAllByTestId('bar-row'); + const noChain = rows.find((el) => el.getAttribute('data-name') === 'NoChainCoin'); + expect(noChain).toBeTruthy(); + // Must not invent a parenthetical blockchain label + expect(noChain?.getAttribute('data-name')).toBe('NoChainCoin'); + expect(noChain?.textContent).not.toMatch(/NoChainCoin \(/); + // Merged volume 42+8 + expect(noChain).toHaveTextContent('50 CHF'); + }); +}); diff --git a/src/__tests__/partner-app-layout.test.tsx b/src/__tests__/partner-app-layout.test.tsx new file mode 100644 index 000000000..492c14cbd --- /dev/null +++ b/src/__tests__/partner-app-layout.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + // jest hoists this factory; mock-prefixed import is allowed in scope + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +describe('partner dashboard block order', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('fetch must not be called in fixture mode'); + }); + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + fetchSpy.mockRestore(); + }); + + it('places the Referral block after the KPI grid and before the volume chart', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('referral-block')).toBeInTheDocument(); + expect(screen.getByTestId('volume-time-chart')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-grid')).toBeInTheDocument(); + }); + + const kpi = screen.getByTestId('kpi-grid'); + const referral = screen.getByTestId('referral-block'); + const volume = screen.getByTestId('volume-time-chart'); + + // Document order: KPI → Referral → Volume chart + const position = kpi.compareDocumentPosition(referral); + expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + + const referralThenVolume = referral.compareDocumentPosition(volume); + expect(referralThenVolume & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); diff --git a/src/__tests__/partner-app-load.test.tsx b/src/__tests__/partner-app-load.test.tsx new file mode 100644 index 000000000..a13449dd9 --- /dev/null +++ b/src/__tests__/partner-app-load.test.tsx @@ -0,0 +1,189 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { StrictMode } from 'react'; +import PartnerDashboardView, { periodRange } from 'src/partner-dashboard/App'; +import { + buildPartnerStatisticFixture, + buildPartnerTimelineFixture, +} from 'src/partner-dashboard/fixtures/partner-statistic.fixture'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +const mockGetPartnerStatistic = jest.fn(); +const mockGetPartnerTimeline = jest.fn(); + +jest.mock('src/hooks/partner-dashboard.hook', () => ({ + usePartnerDashboard: () => ({ + getPartnerStatistic: mockGetPartnerStatistic, + getPartnerTimeline: mockGetPartnerTimeline, + isFixture: false, + }), +})); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +describe('periodRange inclusive day window', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-30T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('spans exactly N calendar days ending at now (30 → from = now − 29 days at UTC midnight)', () => { + // Catches `- (days - 1)` → `- days` which would open a 31-day window under "30 days". + const range = periodRange(30); + expect(range.to).toBe('2026-06-30T12:00:00.000Z'); + expect(range.from).toBe('2026-06-01T00:00:00.000Z'); + }); +}); + +describe('PartnerDashboardView load query and stale guard', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-06-30T12:00:00.000Z')); + mockGetPartnerStatistic.mockReset(); + mockGetPartnerTimeline.mockReset(); + mockGetPartnerStatistic.mockResolvedValue(buildPartnerStatisticFixture()); + mockGetPartnerTimeline.mockResolvedValue(buildPartnerTimelineFixture('Day')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('passes from/to/granularity to both statistic and timeline fetchers', async () => { + render(); + + await waitFor(() => { + expect(mockGetPartnerStatistic).toHaveBeenCalled(); + }); + + const expected = { + from: '2026-06-01T00:00:00.000Z', + to: '2026-06-30T12:00:00.000Z', + granularity: 'Day', + }; + expect(mockGetPartnerStatistic).toHaveBeenCalledWith(expected); + expect(mockGetPartnerTimeline).toHaveBeenCalledWith(expected); + }); + + it('includes granularity in the query when the user switches to Week', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('kpi-grid')).toBeInTheDocument()); + + mockGetPartnerStatistic.mockClear(); + mockGetPartnerTimeline.mockClear(); + + await userEvent.click(screen.getByRole('button', { name: 'Week' })); + + await waitFor(() => { + expect(mockGetPartnerStatistic).toHaveBeenCalled(); + }); + expect(mockGetPartnerStatistic).toHaveBeenCalledWith( + expect.objectContaining({ granularity: 'Week' }), + ); + expect(mockGetPartnerTimeline).toHaveBeenCalledWith( + expect.objectContaining({ granularity: 'Week' }), + ); + }); + + it('ignores a stale slower response when a newer load finishes first', async () => { + // Simulate two overlapping loads (e.g. StrictMode double-effect or a late period switch): + // request A starts, request B starts, B resolves with 999001, then A resolves with 111001. + // Without the request-id guard, A would overwrite B. + type Deferred = { promise: Promise; resolve: (v: T) => void }; + function deferred(): Deferred { + let resolve: (v: T) => void = () => undefined; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + const firstStat = buildPartnerStatisticFixture(); + firstStat.totals.volume.total = 111_001; + const secondStat = buildPartnerStatisticFixture(); + secondStat.totals.volume.total = 999_001; + const tl = buildPartnerTimelineFixture('Day'); + + const statA = deferred(); + const statB = deferred(); + const tlA = deferred(); + const tlB = deferred(); + + let statCalls = 0; + let tlCalls = 0; + mockGetPartnerStatistic.mockImplementation(() => { + statCalls += 1; + return statCalls === 1 ? statA.promise : statB.promise; + }); + mockGetPartnerTimeline.mockImplementation(() => { + tlCalls += 1; + return tlCalls === 1 ? tlA.promise : tlB.promise; + }); + + render( + + + , + ); + + // StrictMode may call load twice on mount — wait until at least one call is in flight + await waitFor(() => expect(mockGetPartnerStatistic).toHaveBeenCalled()); + + // If only one call (no double-invoke), fire a second load via Week click after resolving first... + // Prefer resolving the latest call first when two are pending. + if (statCalls >= 2) { + await act(async () => { + statB.resolve(secondStat); + tlB.resolve(tl); + }); + await waitFor(() => { + expect(screen.getByTestId('kpi-volume')).toHaveTextContent('999,001'); + }); + await act(async () => { + statA.resolve(firstStat); + tlA.resolve(tl); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByTestId('kpi-volume')).toHaveTextContent('999,001'); + expect(screen.getByTestId('kpi-volume')).not.toHaveTextContent('111,001'); + return; + } + + // Single-invoke path: complete first load, then race a period switch against a slow prior. + await act(async () => { + statA.resolve(firstStat); + tlA.resolve(tl); + }); + await waitFor(() => expect(screen.getByTestId('kpi-volume')).toBeInTheDocument()); + + // Next click: hang the new request, then we cannot easily race — assert query instead. + mockGetPartnerStatistic.mockResolvedValue(secondStat); + mockGetPartnerTimeline.mockResolvedValue(tl); + await userEvent.click(screen.getByRole('button', { name: '365 days' })); + await waitFor(() => { + expect(mockGetPartnerStatistic).toHaveBeenCalledWith( + expect.objectContaining({ + from: '2025-07-01T00:00:00.000Z', + }), + ); + }); + }); +}); diff --git a/src/__tests__/partner-chart-theme.test.ts b/src/__tests__/partner-chart-theme.test.ts new file mode 100644 index 000000000..5465a7fd7 --- /dev/null +++ b/src/__tests__/partner-chart-theme.test.ts @@ -0,0 +1,103 @@ +import { baseChartOptions, chartChromeColors } from 'src/partner-dashboard/util/chart-theme'; +import { readThemeCssVar, themeClassName } from 'src/partner-dashboard/util/theme'; + +/** Minimal pod theme tokens so jsdom can resolve the same vars the app uses. */ +const POD_THEME_STYLE = ` + .theme-light { + --border: #dde5f0; + --text: #0b1426; + --text-secondary: #566174; + --text-tertiary: #8d98aa; + --font-sans: Inter, sans-serif; + } + .theme-dark { + --border: rgba(255,255,255,.08); + --text: #f9fafb; + --text-secondary: #a8b5c8; + --text-tertiary: #8a99b7; + --font-sans: Inter, sans-serif; + } +`; + +describe('chart chrome colours follow the requested theme class', () => { + let styleEl: HTMLStyleElement; + + beforeEach(() => { + styleEl = document.createElement('style'); + styleEl.setAttribute('data-testid', 'pod-theme-test-style'); + styleEl.textContent = POD_THEME_STYLE; + document.head.appendChild(styleEl); + + const existing = document.getElementById('partner-dashboard-root'); + if (existing) existing.remove(); + }); + + afterEach(() => { + styleEl.remove(); + document.getElementById('partner-dashboard-root')?.remove(); + document.querySelectorAll('[aria-hidden="true"].theme-light, [aria-hidden="true"].theme-dark').forEach((n) => n.remove()); + }); + + it('readThemeCssVar ignores a stale partner-dashboard-root class', () => { + // Host is still dark (previous theme) while the next render requests light — + // the bug that produced white-on-white legend after dark→light. + const root = document.createElement('div'); + root.id = 'partner-dashboard-root'; + root.className = themeClassName('dark'); + document.body.appendChild(root); + + expect(readThemeCssVar('--text', 'light')).toBe('#0b1426'); + expect(readThemeCssVar('--text', 'dark')).toBe('#f9fafb'); + expect(readThemeCssVar('--border', 'light')).toBe('#dde5f0'); + }); + + it('chartChromeColors uses pod text/border for the requested theme even when root is stale', () => { + const root = document.createElement('div'); + root.id = 'partner-dashboard-root'; + root.className = themeClassName('dark'); + document.body.appendChild(root); + + const light = chartChromeColors('light'); + expect(light.legend).toBe('#0b1426'); + expect(light.grid).toBe('#dde5f0'); + expect(light.axis).toBe('#8d98aa'); + expect(light.mode).toBe('light'); + + const dark = chartChromeColors('dark'); + expect(dark.legend).toBe('#f9fafb'); + expect(dark.grid).toBe('rgba(255,255,255,.08)'); + expect(dark.axis).toBe('#8a99b7'); + expect(dark.mode).toBe('dark'); + }); + + it('baseChartOptions wires legend, grid, axis and tooltip to the matching theme', () => { + const root = document.createElement('div'); + root.id = 'partner-dashboard-root'; + // Stale light host while we build dark chart options (light→dark switch). + root.className = themeClassName('light'); + document.body.appendChild(root); + + const opts = baseChartOptions('dark'); + expect(opts.legend?.labels?.colors).toBe('#f9fafb'); + expect(opts.grid?.borderColor).toBe('rgba(255,255,255,.08)'); + expect(opts.xaxis?.labels?.style?.colors).toBe('#8a99b7'); + expect(opts.theme?.mode).toBe('dark'); + expect(opts.tooltip?.theme).toBe('dark'); + + const lightOpts = baseChartOptions('light'); + expect(lightOpts.legend?.labels?.colors).toBe('#0b1426'); + expect(lightOpts.theme?.mode).toBe('light'); + expect(lightOpts.tooltip?.theme).toBe('light'); + }); + + it('reads from the live root when its theme class already matches', () => { + const root = document.createElement('div'); + root.id = 'partner-dashboard-root'; + root.className = themeClassName('light'); + // Override on the element so we can tell live-root path was used (not probe defaults alone). + root.style.setProperty('--text', '#112233'); + document.body.appendChild(root); + + expect(readThemeCssVar('--text', 'light')).toBe('#112233'); + }); +}); diff --git a/src/__tests__/partner-collapsible-table.test.tsx b/src/__tests__/partner-collapsible-table.test.tsx new file mode 100644 index 000000000..8e975ea13 --- /dev/null +++ b/src/__tests__/partner-collapsible-table.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { + CollapsibleTable, + TABLE_SCROLL_ROW_LIMIT, + tableScrollMaxHeight, +} from 'src/partner-dashboard/components/collapsible-table'; + +function rowsOf(n: number): Array> { + return Array.from({ length: n }, (_, i) => ({ + date: `2026-01-${String(i + 1).padStart(2, '0')}`, + v: String(i), + })); +} + +const columns = [ + { key: 'date', header: 'Date' }, + { key: 'v', header: 'Value', align: 'right' as const }, +]; + +describe('CollapsibleTable scroll cap', () => { + afterEach(() => { + cleanup(); + }); + + it('exports a scroll limit of 20 rows', () => { + expect(TABLE_SCROLL_ROW_LIMIT).toBe(20); + }); + + it('derives max-height from line-height and padding, not a fixed pixel row height', () => { + const css = tableScrollMaxHeight(); + expect(css).toContain('1lh'); + expect(css).toContain('0.75rem'); + expect(css).toContain(String(TABLE_SCROLL_ROW_LIMIT + 1)); + expect(css).not.toMatch(/\d+px/); + }); + + it('does not enable a scroll region at exactly 20 body rows', () => { + // Literal 20 — not TABLE_SCROLL_ROW_LIMIT — so a raised limit still fails this case. + render(); + const region = screen.getByTestId('collapsible-table-scroll'); + expect(region).toHaveAttribute('data-scrollable', 'false'); + expect(region.style.maxHeight).toBe(''); + expect(region.className).not.toMatch(/\boverflow-auto\b/); + expect(region.className).toMatch(/\boverflow-x-auto\b/); + expect(region.querySelector('thead')?.className ?? '').not.toMatch(/sticky/); + }); + + it('enables vertical scroll, max-height, and sticky header at 21 body rows', () => { + // Literal 21 — not limit+1 — so a raised limit still fails this case. + render(); + const region = screen.getByTestId('collapsible-table-scroll'); + expect(region).toHaveAttribute('data-scrollable', 'true'); + expect(region.className).toMatch(/\boverflow-auto\b/); + expect(region.style.maxHeight).toBe(tableScrollMaxHeight(20)); + expect(region.querySelector('thead')?.className).toMatch(/sticky/); + }); + + it('renders the en-dash placeholder when a row is missing a column key', () => { + render( + , + ); + // Missing `v` key → cell falls back to '–' + const cells = screen.getAllByRole('cell'); + expect(cells.map((c) => c.textContent)).toContain('–'); + expect(cells.map((c) => c.textContent)).toContain('2026-01-01'); + }); +}); diff --git a/src/__tests__/partner-dashboard-error.test.tsx b/src/__tests__/partner-dashboard-error.test.tsx new file mode 100644 index 000000000..5f81a67b0 --- /dev/null +++ b/src/__tests__/partner-dashboard-error.test.tsx @@ -0,0 +1,213 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { PartnerErrorBoundary } from 'src/partner-dashboard/components/error-boundary'; +import { ErrorState } from 'src/partner-dashboard/components/error-state'; +import { buildPartnerStatisticFixture, buildPartnerTimelineFixture } from 'src/partner-dashboard/fixtures/partner-statistic.fixture'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + // jest hoists this factory; mock-prefixed import is allowed in scope + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +const mockGetPartnerStatistic = jest.fn(); +const mockGetPartnerTimeline = jest.fn(); + +jest.mock('src/hooks/partner-dashboard.hook', () => ({ + usePartnerDashboard: () => ({ + getPartnerStatistic: mockGetPartnerStatistic, + getPartnerTimeline: mockGetPartnerTimeline, + isFixture: false, + }), +})); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +describe('ErrorState', () => { + it('renders the message and calls onRetry when Retry is pressed', async () => { + const onRetry = jest.fn(); + render(); + + const root = screen.getByTestId('dashboard-error'); + expect(root).toHaveAttribute('role', 'alert'); + expect(root).toHaveTextContent('Partner metrics could not be loaded.'); + + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); +}); + +describe('PartnerDashboardView load error path', () => { + beforeEach(() => { + mockGetPartnerStatistic.mockReset(); + mockGetPartnerTimeline.mockReset(); + }); + + it('shows dashboard-error with Error.message and clears KPIs when getPartnerStatistic fails', async () => { + mockGetPartnerStatistic.mockRejectedValue(new Error('statistic endpoint 503')); + mockGetPartnerTimeline.mockResolvedValue(buildPartnerTimelineFixture('Day')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-error')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('dashboard-error')).toHaveTextContent('statistic endpoint 503'); + expect(screen.queryByTestId('kpi-grid')).not.toBeInTheDocument(); + expect(screen.queryByTestId('volume-time-chart')).not.toBeInTheDocument(); + }); + + it('uses the translated fallback when the rejection is not an Error with a message', async () => { + mockGetPartnerStatistic.mockRejectedValue('raw-string-failure'); + mockGetPartnerTimeline.mockRejectedValue('raw-string-failure'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-error')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('dashboard-error')).toHaveTextContent( + 'Partner metrics could not be loaded.', + ); + }); + + it('uses the fallback when Error.message is empty', async () => { + mockGetPartnerStatistic.mockRejectedValue(new Error('')); + mockGetPartnerTimeline.mockRejectedValue(new Error('')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-error')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('dashboard-error')).toHaveTextContent( + 'Partner metrics could not be loaded.', + ); + }); + + it('Retry reloads metrics via a fresh load (reloadToken path)', async () => { + mockGetPartnerStatistic + .mockRejectedValueOnce(new Error('first failure')) + .mockResolvedValue(buildPartnerStatisticFixture()); + mockGetPartnerTimeline + .mockRejectedValueOnce(new Error('first failure')) + .mockResolvedValue(buildPartnerTimelineFixture('Day')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-error')).toBeInTheDocument(); + }); + + const callsAfterError = mockGetPartnerStatistic.mock.calls.length; + expect(callsAfterError).toBeGreaterThanOrEqual(1); + + await userEvent.click(screen.getByRole('button', { name: 'Retry' })); + + await waitFor(() => { + expect(mockGetPartnerStatistic.mock.calls.length).toBeGreaterThan(callsAfterError); + }); + + await waitFor(() => { + expect(screen.queryByTestId('dashboard-error')).not.toBeInTheDocument(); + expect(screen.getByTestId('kpi-grid')).toBeInTheDocument(); + }); + }); + + it('a later failure clears previously loaded KPIs (statistic/timeline null in catch)', async () => { + mockGetPartnerStatistic + .mockResolvedValueOnce(buildPartnerStatisticFixture()) + .mockRejectedValue(new Error('second failure')); + mockGetPartnerTimeline + .mockResolvedValueOnce(buildPartnerTimelineFixture('Day')) + .mockRejectedValue(new Error('second failure')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('kpi-grid')).toBeInTheDocument(); + }); + + // Period change re-runs load (same path as reloadToken) while KPI data is on screen + await userEvent.click(screen.getByRole('button', { name: '90 days' })); + + await waitFor(() => { + expect(screen.getByTestId('dashboard-error')).toBeInTheDocument(); + }); + expect(screen.getByTestId('dashboard-error')).toHaveTextContent('second failure'); + expect(screen.queryByTestId('kpi-grid')).not.toBeInTheDocument(); + expect(screen.queryByTestId('volume-time-chart')).not.toBeInTheDocument(); + }); +}); + +describe('PartnerErrorBoundary handleRetry', () => { + let consoleErrorSpy: jest.SpyInstance; + let shouldThrow = true; + + function MaybeBoom(): JSX.Element { + if (shouldThrow) { + throw new Error('boundary child boom'); + } + return
recovered
; + } + + beforeEach(() => { + shouldThrow = true; + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('componentDidCatch logs the error and Try again clears the boundary state', async () => { + render( + + + , + ); + + expect(screen.getByTestId('partner-error-boundary')).toBeInTheDocument(); + expect(screen.getByTestId('partner-error-message')).toHaveTextContent('boundary child boom'); + + // componentDidCatch breadcrumb + expect(consoleErrorSpy).toHaveBeenCalled(); + const boundaryLog = consoleErrorSpy.mock.calls.find( + (args) => typeof args[0] === 'string' && args[0].includes('Partner dashboard error boundary'), + ); + expect(boundaryLog).toBeDefined(); + expect(boundaryLog?.[1]).toBeInstanceOf(Error); + + shouldThrow = false; + await userEvent.click(screen.getByRole('button', { name: /Try again/i })); + + expect(screen.getByTestId('boundary-recovered')).toBeInTheDocument(); + expect(screen.queryByTestId('partner-error-boundary')).not.toBeInTheDocument(); + }); + + it('shows Unknown error when the thrown Error has an empty message', () => { + function EmptyMessageBoom(): JSX.Element { + throw new Error(''); + } + + render( + + + , + ); + + expect(screen.getByTestId('partner-error-message')).toHaveTextContent('Unknown error'); + }); +}); diff --git a/src/__tests__/partner-dashboard-guard.test.tsx b/src/__tests__/partner-dashboard-guard.test.tsx new file mode 100644 index 000000000..0b7bd9a94 --- /dev/null +++ b/src/__tests__/partner-dashboard-guard.test.tsx @@ -0,0 +1,94 @@ +import { renderHook } from '@testing-library/react'; + +const mockNavigate = jest.fn(); + +let mockSession: { role: string } | undefined = { role: 'User' }; +let mockIsLoggedIn = true; +let mockIsInitialized = true; + +// Mock @dfx.swiss/react to avoid ES module issues in jest (same pattern as support-helpers). +jest.mock('@dfx.swiss/react', () => ({ + UserRole: { + ADMIN: 'Admin', + USER: 'User', + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + MARKETING: 'Marketing', + }, + useAuthContext: () => ({ session: mockSession }), + useSessionContext: () => ({ isLoggedIn: mockIsLoggedIn }), + useUserContext: () => ({}), +})); + +jest.mock('src/contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: mockIsInitialized }), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +import { UserRole } from '@dfx.swiss/react'; +import { + isPartnerDashboardRole, + PARTNER_DASHBOARD_ROLES, + usePartnerDashboardGuard, +} from 'src/hooks/guard.hook'; + +/** Character-identical to the API enum value — any drift breaks the guard silently. */ +const API_PARTNER_ROLE = 'NonCustodialWalletPartner'; + +describe('PARTNER_DASHBOARD_ROLES matches API enum value', () => { + it('lists exactly the API role string NonCustodialWalletPartner', () => { + expect(PARTNER_DASHBOARD_ROLES).toEqual([API_PARTNER_ROLE]); + expect(PARTNER_DASHBOARD_ROLES[0]).toBe(API_PARTNER_ROLE); + // Guard path uses string equality — prove the allow-list entry is the live value. + expect(isPartnerDashboardRole(API_PARTNER_ROLE)).toBe(true); + }); +}); + +describe('isPartnerDashboardRole', () => { + it('rejects missing and non-partner roles', () => { + expect(isPartnerDashboardRole(undefined)).toBe(false); + expect(isPartnerDashboardRole(UserRole.USER)).toBe(false); + expect(isPartnerDashboardRole(UserRole.ADMIN)).toBe(false); + expect(isPartnerDashboardRole(UserRole.SUPPORT)).toBe(false); + }); + + it('accepts the API role name NonCustodialWalletPartner (runtime string)', () => { + // BEFUND: UserRole has no NonCustodialWalletPartner member yet — compare the + // runtime string the API sends. Character-identical match is required. + expect(isPartnerDashboardRole('NonCustodialWalletPartner')).toBe(true); + }); + + it('rejects the former role name Partner', () => { + expect(isPartnerDashboardRole('Partner')).toBe(false); + }); +}); + +describe('usePartnerDashboardGuard — route not reachable without NonCustodialWalletPartner role', () => { + beforeEach(() => { + mockNavigate.mockReset(); + mockIsLoggedIn = true; + mockIsInitialized = true; + mockSession = { role: UserRole.USER }; + }); + + it('redirects when the session role is not NonCustodialWalletPartner', () => { + renderHook(() => usePartnerDashboardGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('redirects when not logged in', () => { + mockIsLoggedIn = false; + mockSession = undefined; + renderHook(() => usePartnerDashboardGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('does not redirect when the session role is NonCustodialWalletPartner', () => { + mockSession = { role: 'NonCustodialWalletPartner' }; + renderHook(() => usePartnerDashboardGuard()); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/partner-dashboard-nav.test.tsx b/src/__tests__/partner-dashboard-nav.test.tsx new file mode 100644 index 000000000..b1d289f69 --- /dev/null +++ b/src/__tests__/partner-dashboard-nav.test.tsx @@ -0,0 +1,131 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import React from 'react'; + +/** + * Burger-entry visibility is gated in the real Navigation menu: + * `session?.role && isPartnerDashboardRole(session.role)`. + * This renders src/components/navigation.tsx (not a probe) so removing the + * role check fails the test. + */ + +let mockSession: { role: string } | undefined; + +jest.mock('@dfx.swiss/react', () => ({ + UserRole: { + ADMIN: 'Admin', + USER: 'User', + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + MARKETING: 'Marketing', + REALUNIT: 'RealUnit', + }, + useAuthContext: () => ({ session: mockSession }), + useSessionContext: () => ({ isLoggedIn: true, logout: jest.fn() }), + useUserContext: () => ({ hasCustody: false }), +})); + +jest.mock('src/contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: true }), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: jest.fn() }), +})); + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => ({ + translate: (_key: string, defaultValue: string) => defaultValue, + }), +})); + +jest.mock('src/contexts/app-handling.context', () => ({ + CloseType: { CANCEL: 'cancel' }, + useAppHandlingContext: () => ({ + params: {}, + isEmbedded: false, + closeServices: jest.fn(), + }), +})); + +jest.mock('@dfx.swiss/react-components', () => { + const IconVariant = new Proxy( + {}, + { + get: (_t, prop: string) => prop, + }, + ); + return { + IconVariant, + IconColor: { BLUE: 'blue', RED: 'red' }, + IconSize: { LG: 'lg' }, + DfxIcon: () => , + StyledButton: ({ label, onClick }: { label: string; onClick?: () => void }) => ( + + ), + StyledButtonColor: { STURDY_WHITE: 'sturdy-white' }, + StyledButtonWidth: { FULL: 'full' }, + StyledLink: ({ + label, + onClick, + url, + }: { + label: string; + onClick?: () => void; + url?: string; + }) => ( + + {label} + + ), + }; +}); + +import { UserRole } from '@dfx.swiss/react'; +import { Navigation } from 'src/components/navigation'; + +/** Label from navigation.tsx → translate('screens/partner', 'NC Partner Program'). */ +const PARTNER_NAV_LABEL = 'NC Partner Program'; + +function renderOpenNavigation(): void { + render( + + + , + ); +} + +describe('Non-Custodial Partner Program burger entry (real Navigation)', () => { + beforeEach(() => { + mockSession = undefined; + }); + + it('hides the entry without a session role', () => { + mockSession = undefined; + renderOpenNavigation(); + expect(screen.queryByText(PARTNER_NAV_LABEL)).not.toBeInTheDocument(); + }); + + it('hides the entry for a non-partner role', () => { + mockSession = { role: UserRole.USER }; + const { unmount } = render( + + + , + ); + expect(screen.queryByText(PARTNER_NAV_LABEL)).not.toBeInTheDocument(); + unmount(); + + mockSession = { role: UserRole.ADMIN }; + renderOpenNavigation(); + expect(screen.queryByText(PARTNER_NAV_LABEL)).not.toBeInTheDocument(); + }); + + it('shows the entry for the NonCustodialWalletPartner role', () => { + mockSession = { role: 'NonCustodialWalletPartner' }; + renderOpenNavigation(); + expect(screen.getByText(PARTNER_NAV_LABEL)).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/partner-dashboard-screen-wiring.test.tsx b/src/__tests__/partner-dashboard-screen-wiring.test.tsx new file mode 100644 index 000000000..2dc13d3b3 --- /dev/null +++ b/src/__tests__/partner-dashboard-screen-wiring.test.tsx @@ -0,0 +1,98 @@ +import { render, waitFor } from '@testing-library/react'; +import { LayoutConfig } from 'src/contexts/layout-config.context'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +/** + * Wiring test: PartnerDashboardScreen must call usePartnerDashboardGuard. + * Logic of the guard is covered in partner-dashboard-guard.test.tsx; this file + * proves the screen actually invokes it (removing the call must fail here). + */ + +const mockNavigate = jest.fn(); + +let mockSession: { role: string } | undefined = { role: 'User' }; +let mockIsLoggedIn = true; +let mockIsInitialized = true; + +jest.mock('@dfx.swiss/react', () => ({ + UserRole: { + ADMIN: 'Admin', + USER: 'User', + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + MARKETING: 'Marketing', + }, + useAuthContext: () => ({ session: mockSession }), + useSessionContext: () => ({ isLoggedIn: mockIsLoggedIn }), + useUserContext: () => ({}), +})); + +jest.mock('src/contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: mockIsInitialized }), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +const layoutOptionsCalls: LayoutConfig[] = []; + +jest.mock('src/hooks/layout-config.hook', () => ({ + useLayoutOptions: (config: LayoutConfig) => { + layoutOptionsCalls.push(config); + }, +})); + +// Intentionally NOT mocking src/hooks/guard.hook — the real guard must run. + +import PartnerDashboardScreen from 'src/screens/partner-dashboard.screen'; + +describe('PartnerDashboardScreen wires usePartnerDashboardGuard', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + mockNavigate.mockReset(); + mockIsLoggedIn = true; + mockIsInitialized = true; + mockSession = { role: 'User' }; + layoutOptionsCalls.length = 0; + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + }); + + it('redirects when the session role is not NonCustodialWalletPartner', async () => { + render(); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + }); + + it('does not redirect when the session role is NonCustodialWalletPartner', async () => { + mockSession = { role: 'NonCustodialWalletPartner' }; + render(); + + // Allow effects to flush; guard must stay silent for the partner role. + await waitFor(() => { + expect(layoutOptionsCalls.length).toBeGreaterThan(0); + }); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/partner-dashboard.hook.test.ts b/src/__tests__/partner-dashboard.hook.test.ts new file mode 100644 index 000000000..de8a4114c --- /dev/null +++ b/src/__tests__/partner-dashboard.hook.test.ts @@ -0,0 +1,63 @@ +import { renderHook } from '@testing-library/react'; +import { usePartnerDashboard } from 'src/hooks/partner-dashboard.hook'; + +const mockCall = jest.fn(); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: mockCall }), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: jest.fn() }), +})); + +describe('usePartnerDashboard via useGuardedApi', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + + beforeEach(() => { + mockCall.mockReset().mockResolvedValue({}); + process.env.REACT_APP_PARTNER_FIXTURE = 'false'; + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + }); + + it('calls statistic/partner and statistic/partner/timeline with session-backed call', async () => { + const { result } = renderHook(() => usePartnerDashboard()); + expect(result.current.isFixture).toBe(false); + + await result.current.getPartnerStatistic({ + from: '2026-01-01T00:00:00.000Z', + to: '2026-01-31T00:00:00.000Z', + }); + expect(mockCall).toHaveBeenCalledWith({ + url: 'statistic/partner?from=2026-01-01T00%3A00%3A00.000Z&to=2026-01-31T00%3A00%3A00.000Z', + method: 'GET', + }); + + mockCall.mockClear(); + await result.current.getPartnerTimeline({ granularity: 'Week' }); + expect(mockCall).toHaveBeenCalledWith({ + url: 'statistic/partner/timeline?granularity=Week', + method: 'GET', + }); + }); + + it('omits the query string when no filter params are provided', async () => { + const { result } = renderHook(() => usePartnerDashboard()); + + await result.current.getPartnerStatistic({}); + expect(mockCall).toHaveBeenCalledWith({ + url: 'statistic/partner', + method: 'GET', + }); + + mockCall.mockClear(); + await result.current.getPartnerTimeline({}); + expect(mockCall).toHaveBeenCalledWith({ + url: 'statistic/partner/timeline', + method: 'GET', + }); + }); +}); diff --git a/src/__tests__/partner-fixture-no-api.test.tsx b/src/__tests__/partner-fixture-no-api.test.tsx new file mode 100644 index 000000000..3a238859a --- /dev/null +++ b/src/__tests__/partner-fixture-no-api.test.tsx @@ -0,0 +1,113 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { usePartnerDashboard } from 'src/hooks/partner-dashboard.hook'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { PartnerErrorBoundary } from 'src/partner-dashboard/components/error-boundary'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + // jest hoists this factory; mock-prefixed import is allowed in scope + useSettingsContext: () => mockSettingsState, +})); + +const mockCall = jest.fn(); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: mockCall }), +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +function HookProbe(): JSX.Element { + const { getPartnerStatistic, getPartnerTimeline, isFixture } = usePartnerDashboard(); + return ( +
+ {String(isFixture)} + +
+ ); +} + +describe('fixture mode makes no API calls (D5)', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + mockCall.mockReset(); + fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('fetch must not be called in fixture mode'); + }); + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + fetchSpy.mockRestore(); + }); + + it('usePartnerDashboard returns fixtures without calling fetch or guarded API', async () => { + render(); + expect(screen.getByTestId('is-fixture')).toHaveTextContent('true'); + + await act(async () => { + screen.getByTestId('load').click(); + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('PartnerDashboardView renders in fixture mode without network', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('partner-dashboard-root')).toHaveAttribute('data-fixture', 'true'); + }); + await waitFor(() => { + expect(screen.getByTestId('partner-header')).toBeInTheDocument(); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(mockCall).not.toHaveBeenCalled(); + }); +}); + +describe('error boundary keeps the shell usable (D5)', () => { + function Boom(): JSX.Element { + throw new Error('simulated context bootstrap failure'); + } + + // Mute expected React error-boundary noise + let consoleErrorSpy: jest.SpyInstance; + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + }); + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + it('renders an embedded message instead of unmounting the page', () => { + render( + + + , + ); + + expect(screen.getByTestId('partner-error-boundary')).toBeInTheDocument(); + expect(screen.getByTestId('partner-error-message')).toHaveTextContent( + 'simulated context bootstrap failure', + ); + expect(screen.getByRole('button', { name: /Try again/i })).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/partner-format.test.ts b/src/__tests__/partner-format.test.ts new file mode 100644 index 000000000..60bc50d45 --- /dev/null +++ b/src/__tests__/partner-format.test.ts @@ -0,0 +1,83 @@ +import { + ABSENT_LABEL, + computeConversionRate, + displayNullable, + formatAmount, + formatCount, + formatPercent, +} from 'src/partner-dashboard/util/format'; + +describe('partner format helpers', () => { + it('uses the en-dash placeholder for absent values (never a zero or prose label)', () => { + expect(ABSENT_LABEL).toBe('–'); + expect(ABSENT_LABEL).not.toBe('0'); + expect(ABSENT_LABEL.toLowerCase()).not.toMatch(/angabe/); + }); + + it('formats amounts with thousands separators and currency code', () => { + expect(formatAmount(11858002.52, 'CHF')).toMatch(/11.?858.?002[,.]52 CHF/); + expect(formatAmount(0, 'EUR')).toBe('0.00 EUR'); + }); + + it('formats counts with thousands separators', () => { + expect(formatCount(126547)).toMatch(/126.?547/); + expect(formatCount(0)).toBe('0'); + }); + + it('formats rates as percent with one decimal place', () => { + expect(formatPercent(0.1538)).toMatch(/15[,.]4 %/); + expect(formatPercent(0)).toMatch(/0[,.]0 %/); + }); + + it('returns empty string for null format inputs (caller handles display)', () => { + expect(formatAmount(null, 'CHF')).toBe(''); + expect(formatCount(null)).toBe(''); + expect(formatPercent(null)).toBe(''); + }); + + it('displayNullable distinguishes null (genuinely absent) from 0 (real zero)', () => { + const absent = displayNullable(null, (n) => formatCount(n)); + expect(absent.kind).toBe('absent'); + if (absent.kind === 'absent') { + expect(absent.text).toBe('–'); + expect(absent.text).not.toBe('0'); + } + + const zero = displayNullable(0, (n) => formatCount(n)); + expect(zero.kind).toBe('value'); + if (zero.kind === 'value') { + expect(zero.text).toBe('0'); + } + }); + + it('displayNullable marks undefined as empty (distinct from absent null)', () => { + const empty = displayNullable(undefined, (n) => formatCount(n)); + expect(empty.kind).toBe('empty'); + if (empty.kind === 'empty') { + expect(empty.text).toBe('–'); + } + // null must remain the 'absent' path — not the same kind as undefined + expect(displayNullable(null, (n) => formatCount(n)).kind).toBe('absent'); + }); + + describe('computeConversionRate', () => { + it('returns tradingUsers / registeredUsers for a normal partner', () => { + const rate = computeConversionRate(24360, 126547); + expect(rate).toBeCloseTo(0.1925, 4); + }); + + it('returns null (not 0, NaN, or Infinity) when there are no registered users', () => { + const rate = computeConversionRate(0, 0); + expect(rate).toBeNull(); + expect(rate).not.toBe(0); + expect(Number.isNaN(rate as unknown as number)).toBe(false); + expect(rate).not.toBe(Infinity); + }); + + it('returns 0 (distinct from null) when registered users exist but none traded', () => { + const rate = computeConversionRate(0, 100); + expect(rate).toBe(0); + expect(rate).not.toBeNull(); + }); + }); +}); diff --git a/src/__tests__/partner-guards-legacy.test.tsx b/src/__tests__/partner-guards-legacy.test.tsx new file mode 100644 index 000000000..95f79a470 --- /dev/null +++ b/src/__tests__/partner-guards-legacy.test.tsx @@ -0,0 +1,273 @@ +import { renderHook } from '@testing-library/react'; + +const mockNavigate = jest.fn(); + +let mockSession: { role: string; address?: string } | undefined = { role: 'User', address: '0xabc' }; +let mockIsLoggedIn = true; +let mockIsInitialized = true; +let mockUser: { kyc: { level: number } } | undefined = { kyc: { level: 10 } }; +let mockIsUserLoading = false; + +// Mock @dfx.swiss/react to avoid ES module issues in jest (same pattern as partner-dashboard-guard). +jest.mock('@dfx.swiss/react', () => ({ + UserRole: { + ACCOUNT: 'Account', + USER: 'User', + VIP: 'VIP', + BETA: 'Beta', + ADMIN: 'Admin', + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + KYC_CLIENT_COMPANY: 'KycClientCompany', + CUSTODY: 'Custody', + REALUNIT: 'RealUnit', + MARKETING: 'Marketing', + MONITORING: 'Monitoring', + }, + useAuthContext: () => ({ session: mockSession }), + useSessionContext: () => ({ isLoggedIn: mockIsLoggedIn }), + useUserContext: () => ({ user: mockUser, isUserLoading: mockIsUserLoading }), +})); + +jest.mock('src/contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: mockIsInitialized }), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: mockNavigate }), +})); + +import { UserRole } from '@dfx.swiss/react'; +import { + SUPPORT_DASHBOARD_ROLES, + useAddressGuard, + useAdminGuard, + useComplianceGuard, + useKycLevelGuard, + useRealunitGuard, + useSupportDashboardGuard, + useUserGuard, +} from 'src/hooks/guard.hook'; + +function resetGuards(): void { + mockNavigate.mockReset(); + mockIsLoggedIn = true; + mockIsInitialized = true; + mockSession = { role: UserRole.USER, address: '0xabc' }; + mockUser = { kyc: { level: 10 } }; + mockIsUserLoading = false; +} + +describe('SUPPORT_DASHBOARD_ROLES allow-list', () => { + it('includes Admin, Compliance, Support, and Marketing', () => { + expect(SUPPORT_DASHBOARD_ROLES).toEqual([ + UserRole.ADMIN, + UserRole.COMPLIANCE, + UserRole.SUPPORT, + UserRole.MARKETING, + ]); + }); +}); + +describe('useAddressGuard (session + active address)', () => { + beforeEach(resetGuards); + + it('uses default redirectPath "/" when called without arguments', () => { + mockIsLoggedIn = false; + renderHook(() => useAddressGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('redirects to the path when not logged in', () => { + mockIsLoggedIn = false; + renderHook(() => useAddressGuard('/home')); + expect(mockNavigate).toHaveBeenCalledWith('/home', { setRedirect: true }); + }); + + it('redirects to /connect when logged in without an active address', () => { + mockSession = { role: UserRole.USER }; + renderHook(() => useAddressGuard('/home')); + expect(mockNavigate).toHaveBeenCalledWith('/connect', { setRedirect: true }); + }); + + it('does not redirect when logged in with an address', () => { + renderHook(() => useAddressGuard('/home')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not redirect when inactive or wallet not initialized', () => { + mockIsLoggedIn = false; + renderHook(() => useAddressGuard('/home', false)); + expect(mockNavigate).not.toHaveBeenCalled(); + + mockIsInitialized = false; + renderHook(() => useAddressGuard('/home', true)); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); + +describe('useUserGuard (session only, no address check)', () => { + beforeEach(resetGuards); + + it('uses default redirectPath "/" when called without arguments', () => { + mockIsLoggedIn = false; + renderHook(() => useUserGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('redirects when not logged in', () => { + mockIsLoggedIn = false; + renderHook(() => useUserGuard('/login')); + expect(mockNavigate).toHaveBeenCalledWith('/login', { setRedirect: true }); + }); + + it('does not redirect for a logged-in user without address (unlike useAddressGuard)', () => { + mockSession = { role: UserRole.USER }; + renderHook(() => useUserGuard('/login')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); + +describe('useAdminGuard', () => { + beforeEach(resetGuards); + + it('uses default redirectPath "/" when called without arguments', () => { + mockSession = { role: UserRole.USER, address: '0x1' }; + renderHook(() => useAdminGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('redirects non-admin roles', () => { + mockSession = { role: UserRole.USER, address: '0x1' }; + renderHook(() => useAdminGuard('/')); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('allows Admin', () => { + mockSession = { role: UserRole.ADMIN, address: '0x1' }; + renderHook(() => useAdminGuard('/')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('redirects when not logged in', () => { + mockIsLoggedIn = false; + mockSession = undefined; + renderHook(() => useAdminGuard('/out')); + expect(mockNavigate).toHaveBeenCalledWith('/out', { setRedirect: true }); + }); +}); + +describe('useRealunitGuard', () => { + beforeEach(resetGuards); + + it('uses default redirectPath "/" when called without arguments', () => { + mockSession = { role: UserRole.USER, address: '0x1' }; + renderHook(() => useRealunitGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it.each([UserRole.ADMIN, UserRole.REALUNIT, UserRole.COMPLIANCE])( + 'allows %s', + (role) => { + mockSession = { role, address: '0x1' }; + renderHook(() => useRealunitGuard('/')); + expect(mockNavigate).not.toHaveBeenCalled(); + }, + ); + + it('redirects Support (not in Realunit allow-list)', () => { + mockSession = { role: UserRole.SUPPORT, address: '0x1' }; + renderHook(() => useRealunitGuard('/')); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); +}); + +describe('useComplianceGuard', () => { + beforeEach(resetGuards); + + it('uses default redirectPath "/" when called without arguments', () => { + mockSession = { role: UserRole.USER, address: '0x1' }; + renderHook(() => useComplianceGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it.each([UserRole.ADMIN, UserRole.COMPLIANCE])('allows %s', (role) => { + mockSession = { role, address: '0x1' }; + renderHook(() => useComplianceGuard('/')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('redirects Marketing (not in compliance allow-list)', () => { + mockSession = { role: UserRole.MARKETING, address: '0x1' }; + renderHook(() => useComplianceGuard('/')); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); +}); + +describe('useSupportDashboardGuard', () => { + beforeEach(resetGuards); + + it('uses default redirectPath "/" when called without arguments', () => { + mockSession = { role: UserRole.USER, address: '0x1' }; + renderHook(() => useSupportDashboardGuard()); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it.each(SUPPORT_DASHBOARD_ROLES)('allows support-dashboard role %s', (role) => { + mockSession = { role, address: '0x1' }; + renderHook(() => useSupportDashboardGuard('/')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('redirects plain User', () => { + mockSession = { role: UserRole.USER, address: '0x1' }; + renderHook(() => useSupportDashboardGuard('/')); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('does nothing when isActive is false', () => { + mockSession = { role: UserRole.USER, address: '0x1' }; + renderHook(() => useSupportDashboardGuard('/', false)); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); + +describe('useKycLevelGuard', () => { + beforeEach(resetGuards); + + it('redirects when user kyc level is below the minimum', () => { + mockUser = { kyc: { level: 10 } }; + renderHook(() => useKycLevelGuard(30, '/kyc')); + expect(mockNavigate).toHaveBeenCalledWith('/kyc', { setRedirect: true }); + }); + + it('does not redirect when level meets the minimum', () => { + mockUser = { kyc: { level: 50 } }; + renderHook(() => useKycLevelGuard(30, '/kyc')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('uses default redirectPath "/" when only minLevel is provided', () => { + mockUser = { kyc: { level: 0 } }; + renderHook(() => useKycLevelGuard(30)); + expect(mockNavigate).toHaveBeenCalledWith('/', { setRedirect: true }); + }); + + it('does not redirect while user is loading or wallet is not initialized', () => { + mockUser = { kyc: { level: 0 } }; + mockIsUserLoading = true; + renderHook(() => useKycLevelGuard(30, '/kyc')); + expect(mockNavigate).not.toHaveBeenCalled(); + + mockIsUserLoading = false; + mockIsInitialized = false; + renderHook(() => useKycLevelGuard(30, '/kyc')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does not redirect when user is missing', () => { + mockUser = undefined; + renderHook(() => useKycLevelGuard(30, '/kyc')); + expect(mockNavigate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/partner-header-switchers.test.tsx b/src/__tests__/partner-header-switchers.test.tsx new file mode 100644 index 000000000..bdd4b6972 --- /dev/null +++ b/src/__tests__/partner-header-switchers.test.tsx @@ -0,0 +1,138 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { PartnerHeader } from 'src/partner-dashboard/components/header'; +import { + PARTNER_THEME_STORAGE_KEY, + resolveInitialTheme, +} from 'src/partner-dashboard/util/theme'; +import { + mockChangeLanguage, + mockSettingsState, + PARTNER_TEST_LANGUAGES, + resetMockSettings, +} from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + // jest allows out-of-scope vars prefixed with `mock` inside the factory + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +describe('partner header theme switcher', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + window.localStorage.removeItem(PARTNER_THEME_STORAGE_KEY); + resetMockSettings(); + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + window.localStorage.removeItem(PARTNER_THEME_STORAGE_KEY); + }); + + it('changes the theme class on the partner root when Dark is pressed', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('partner-dashboard-root')).toHaveAttribute('data-theme', 'light'); + }); + + await userEvent.click(screen.getByTestId('theme-dark')); + + const root = screen.getByTestId('partner-dashboard-root'); + expect(root).toHaveAttribute('data-theme', 'dark'); + expect(root.className).toContain('theme-dark'); + expect(root.className).not.toContain('theme-light'); + expect(screen.getByTestId('theme-dark')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('theme-light')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('persists the choice so a remount restores dark theme', async () => { + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByTestId('theme-dark')).toBeInTheDocument(); + }); + await userEvent.click(screen.getByTestId('theme-dark')); + expect(window.localStorage.getItem(PARTNER_THEME_STORAGE_KEY)).toBe('dark'); + expect(resolveInitialTheme()).toBe('dark'); + + unmount(); + + render(); + await waitFor(() => { + const root = screen.getByTestId('partner-dashboard-root'); + expect(root).toHaveAttribute('data-theme', 'dark'); + expect(root.className).toContain('theme-dark'); + }); + }); +}); + +describe('partner header language switcher', () => { + beforeEach(() => { + resetMockSettings(); + }); + + it('calls the app changeLanguage with the selected Language object (not a local copy)', async () => { + const onThemeChange = jest.fn(); + render(); + + expect(screen.getByTestId('language-switcher')).toBeInTheDocument(); + // All app languages are offered (DE/EN/FR/IT) + expect(screen.getByTestId('lang-de')).toBeInTheDocument(); + expect(screen.getByTestId('lang-en')).toBeInTheDocument(); + expect(screen.getByTestId('lang-fr')).toBeInTheDocument(); + expect(screen.getByTestId('lang-it')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('lang-de')); + + expect(mockChangeLanguage).toHaveBeenCalledTimes(1); + expect(mockChangeLanguage).toHaveBeenCalledWith(PARTNER_TEST_LANGUAGES[0]); + // Same object identity the settings context holds — proves we did not invent a parallel language state + expect(mockChangeLanguage.mock.calls[0][0]).toBe(PARTNER_TEST_LANGUAGES[0]); + }); + + it('marks the active language via aria-pressed from settings context state', async () => { + const onThemeChange = jest.fn(); + const { rerender } = render(); + + expect(screen.getByTestId('lang-en')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('lang-de')).toHaveAttribute('aria-pressed', 'false'); + + await userEvent.click(screen.getByTestId('lang-de')); + // mock updates mockSettingsState.language; re-render as a real context consumer would + expect(mockSettingsState.language?.symbol).toBe('DE'); + rerender(); + + expect(screen.getByTestId('lang-de')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('lang-en')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('calls onThemeChange("light") when Light is pressed', async () => { + const onThemeChange = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('theme-light')); + expect(onThemeChange).toHaveBeenCalledWith('light'); + }); + + it('hides the language switcher when availableLanguages is empty', () => { + mockSettingsState.availableLanguages = []; + const onThemeChange = jest.fn(); + render(); + + expect(screen.queryByTestId('language-switcher')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/partner-horizontal-bar.test.tsx b/src/__tests__/partner-horizontal-bar.test.tsx new file mode 100644 index 000000000..f6f065ed2 --- /dev/null +++ b/src/__tests__/partner-horizontal-bar.test.tsx @@ -0,0 +1,168 @@ +import { render, screen } from '@testing-library/react'; +import { HorizontalBarList } from 'src/partner-dashboard/components/horizontal-bar-list'; + +describe('HorizontalBarList — every row is real, none dropped by magnitude', () => { + it('renders a thin (small non-zero) row alongside a large one, with its exact value', () => { + render( + , + ); + + const rows = screen.getAllByTestId('bar-row'); + expect(rows).toHaveLength(2); + + const zecRow = rows.find((el) => el.getAttribute('data-name') === 'ZEC'); + expect(zecRow).toBeTruthy(); + // Exact pinned amount — never rounded away, never withheld as a gap + expect(zecRow).toHaveTextContent('3 CHF'); + expect(zecRow).not.toHaveTextContent('–'); + }); +}); + +describe('HorizontalBarList — a category with no activity at all disappears', () => { + it('drops a row with zero volume AND zero transactions (genuinely unused)', () => { + render( + , + ); + + const rows = screen.getAllByTestId('bar-row'); + expect(rows).toHaveLength(1); + expect(rows.find((el) => el.getAttribute('data-name') === 'Card')).toBeUndefined(); + expect(screen.queryByText('Card')).not.toBeInTheDocument(); + }); + + it('keeps a row with zero volume but real transactions (not unused)', () => { + render( + , + ); + + const rows = screen.getAllByTestId('bar-row'); + expect(rows).toHaveLength(2); + expect(rows.find((el) => el.getAttribute('data-name') === 'FreeFiat')).toBeTruthy(); + }); + + it('keeps a row with volume but zero counted transactions (not unused)', () => { + render( + , + ); + + const rows = screen.getAllByTestId('bar-row'); + expect(rows).toHaveLength(2); + expect(rows.find((el) => el.getAttribute('data-name') === 'Oddity')).toBeTruthy(); + }); + + it('shows the empty state when every row in the period is unused', () => { + render( + , + ); + + expect(screen.queryAllByTestId('bar-row')).toHaveLength(0); + expect(screen.getByText('No data.')).toBeInTheDocument(); + }); +}); + +describe('HorizontalBarList — zero max volume branch', () => { + it('renders zero-width bars when every active row has volume 0 (maxVolume === 0)', () => { + render( + , + ); + + const rows = screen.getAllByTestId('bar-row'); + expect(rows).toHaveLength(2); + // Width formula: maxVolume > 0 ? … : 0 — bars must not claim 100% + const fills = rows.map((row) => row.querySelector('[role="presentation"] > div') as HTMLElement); + for (const fill of fills) { + expect(fill.style.width).toBe('0%'); + } + }); +}); + +describe('HorizontalBarList — bar width proportional to volume', () => { + it('pins fill widths to volume/maxVolume (not the inverse)', () => { + render( + , + ); + + const rows = screen.getAllByTestId('bar-row'); + const chf = rows.find((el) => el.getAttribute('data-name') === 'CHF'); + const eur = rows.find((el) => el.getAttribute('data-name') === 'EUR'); + expect(chf).toBeTruthy(); + expect(eur).toBeTruthy(); + const chfFill = chf?.querySelector('[role="presentation"] > div') as HTMLElement; + const eurFill = eur?.querySelector('[role="presentation"] > div') as HTMLElement; + expect(chfFill.style.width).toBe('100%'); + expect(eurFill.style.width).toBe('50%'); + }); + + it('assigns distinct backgroundColor for each ranked row index', () => { + render( + , + ); + + const fills = screen + .getAllByTestId('bar-row') + .map((row) => (row.querySelector('[role="presentation"] > div') as HTMLElement).style.backgroundColor); + expect(new Set(fills).size).toBe(4); + }); +}); \ No newline at end of file diff --git a/src/__tests__/partner-i18n-edge.test.ts b/src/__tests__/partner-i18n-edge.test.ts new file mode 100644 index 000000000..8e033a0c8 --- /dev/null +++ b/src/__tests__/partner-i18n-edge.test.ts @@ -0,0 +1,15 @@ +import { getPartnerLocale } from 'src/partner-dashboard/util/i18n'; + +describe('getPartnerLocale defensive base fallback', () => { + it('covers the split base ?? en arm via a non-string truthy lang with empty first segment', () => { + // Force raw to a value whose split('-')[0] is undefined after optional chain: + // a custom object whose split returns [''] would still toLowerCase to ''. + // The only way to hit `?? 'en'` is when [0]?.toLowerCase() yields null/undefined. + // A string never does that — simulate with a split that returns an empty array. + const weird = { + split: () => [] as string[], + }; + // lang is truthy so we do not fall through to i18n.language + expect(getPartnerLocale(weird as unknown as string)).toBe('en-US'); + }); +}); diff --git a/src/__tests__/partner-i18n.test.ts b/src/__tests__/partner-i18n.test.ts new file mode 100644 index 000000000..42914110f --- /dev/null +++ b/src/__tests__/partner-i18n.test.ts @@ -0,0 +1,82 @@ +import i18n from 'i18next'; +import { + applyStoredPartnerLanguage, + getPartnerLocale, + partnerTranslate, + usePartnerTranslation, +} from 'src/partner-dashboard/util/i18n'; +import { renderHook } from '@testing-library/react'; + +describe('partner i18n helpers', () => { + it('getPartnerLocale maps known language bases and falls back to en-US', () => { + expect(getPartnerLocale('en')).toBe('en-US'); + expect(getPartnerLocale('de')).toBe('de-CH'); + expect(getPartnerLocale('de-AT')).toBe('de-CH'); + expect(getPartnerLocale('fr')).toBe('fr-FR'); + expect(getPartnerLocale('it')).toBe('it-IT'); + expect(getPartnerLocale('xx')).toBe('en-US'); + expect(getPartnerLocale('ZH-cn')).toBe('en-US'); + }); + + it('getPartnerLocale uses i18n.language when lang is omitted', () => { + const previous = i18n.language; + void i18n.changeLanguage('de'); + expect(getPartnerLocale()).toBe('de-CH'); + void i18n.changeLanguage(previous || 'en'); + }); + + it('getPartnerLocale falls back to en when both lang and i18n.language are empty', () => { + const previous = i18n.language; + // Force the `lang ?? i18n.language ?? 'en'` third arm + Object.defineProperty(i18n, 'language', { + configurable: true, + get: () => undefined, + }); + try { + expect(getPartnerLocale(undefined)).toBe('en-US'); + expect(getPartnerLocale('')).toBe('en-US'); + } finally { + Object.defineProperty(i18n, 'language', { + configurable: true, + writable: true, + value: previous, + }); + } + }); + + it('partnerTranslate returns the default English key when no translation is loaded for en', () => { + const text = partnerTranslate('Total volume'); + expect(typeof text).toBe('string'); + expect(text.length).toBeGreaterThan(0); + }); + + it('applyStoredPartnerLanguage is a no-op (kept for index.tsx import)', () => { + expect(() => applyStoredPartnerLanguage()).not.toThrow(); + }); + + it('usePartnerTranslation exposes translate, locale, and language', () => { + const { result } = renderHook(() => usePartnerTranslation()); + expect(result.current.translate('Buy')).toBeTruthy(); + expect(result.current.locale).toMatch(/-/); + expect(typeof result.current.language).toBe('string'); + }); + + it('resolves German partner labels and de-CH number locale via screens/partner namespace', async () => { + // Catches PARTNER_NS → 'screens/partners' which silently falls back to English keys. + const previous = i18n.language; + try { + await i18n.changeLanguage('de'); + expect(partnerTranslate('Total volume')).toBe('Gesamtvolumen'); + expect(partnerTranslate('This period')).toBe('Dieser Zeitraum'); + expect(getPartnerLocale()).toBe('de-CH'); + // de-CH formats with apostrophe thousands separators + const formatted = (1234.5).toLocaleString(getPartnerLocale(), { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + expect(formatted).toMatch(/1['’\u202f\s]?234/); + } finally { + await i18n.changeLanguage(previous || 'en'); + } + }); +}); diff --git a/src/__tests__/partner-kpi-groups.test.tsx b/src/__tests__/partner-kpi-groups.test.tsx new file mode 100644 index 000000000..7b2803e06 --- /dev/null +++ b/src/__tests__/partner-kpi-groups.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { buildPartnerStatisticFixture } from 'src/partner-dashboard/fixtures/partner-statistic.fixture'; +import { formatAmount, formatAmountWhole, formatCount } from 'src/partner-dashboard/util/format'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +jest.mock('src/contexts/settings.context', () => ({ + // jest hoists this factory; mock-prefixed import is allowed in scope + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +describe('partner dashboard KPI groups', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + fetchSpy = jest.spyOn(global, 'fetch').mockImplementation(() => { + throw new Error('fetch must not be called in fixture mode'); + }); + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + fetchSpy.mockRestore(); + }); + + it('splits period and all-time KPIs into labeled sections with correct membership', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('kpi-period-section')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-alltime-section')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-grid')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-alltime-grid')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('kpi-period-heading')).toHaveTextContent('This period'); + expect(screen.getByTestId('kpi-alltime-heading')).toHaveTextContent('All-time totals'); + + const periodSection = screen.getByTestId('kpi-period-section'); + const alltimeSection = screen.getByTestId('kpi-alltime-section'); + const periodGrid = screen.getByTestId('kpi-grid'); + const alltimeGrid = screen.getByTestId('kpi-alltime-grid'); + + expect(periodSection.contains(periodGrid)).toBe(true); + expect(alltimeSection.contains(alltimeGrid)).toBe(true); + + const periodKpis = [ + 'kpi-volume', + 'kpi-transactions', + 'kpi-avg', + 'kpi-active-users', + 'kpi-new-users', + ] as const; + const alltimeKpis = ['kpi-registered', 'kpi-trading-users', 'kpi-lifetime-volume'] as const; + + for (const testId of periodKpis) { + const tile = screen.getByTestId(testId); + expect(periodGrid.contains(tile)).toBe(true); + expect(alltimeGrid.contains(tile)).toBe(false); + } + + for (const testId of alltimeKpis) { + const tile = screen.getByTestId(testId); + expect(alltimeGrid.contains(tile)).toBe(true); + expect(periodGrid.contains(tile)).toBe(false); + } + + // Document order: period grid before all-time grid + const order = periodGrid.compareDocumentPosition(alltimeGrid); + expect(order & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('pins each period KPI to the matching fixture field (not a sibling)', async () => { + // Deterministic fixture values — catches activeUsers↔newUsers, volume.buy↔total, + // formatAmountWhole↔formatAmount swaps that membership-only tests miss. + const fixture = buildPartnerStatisticFixture(); + const locale = 'en-US'; + const currency = fixture.currency; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('kpi-volume')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('kpi-volume')).toHaveTextContent( + formatAmountWhole(fixture.totals.volume.total, currency, locale), + ); + expect(screen.getByTestId('kpi-transactions')).toHaveTextContent( + formatCount(fixture.totals.transactions.total, locale), + ); + expect(screen.getByTestId('kpi-avg')).toHaveTextContent( + formatAmount(fixture.totals.averageTransactionVolume, currency, 2, locale), + ); + expect(screen.getByTestId('kpi-active-users')).toHaveTextContent( + formatCount(fixture.totals.activeUsers, locale), + ); + expect(screen.getByTestId('kpi-new-users')).toHaveTextContent( + formatCount(fixture.totals.newUsers, locale), + ); + // active and new must not be interchangeable + expect(fixture.totals.activeUsers).not.toBe(fixture.totals.newUsers); + expect(screen.getByTestId('kpi-active-users')).not.toHaveTextContent( + formatCount(fixture.totals.newUsers, locale), + ); + }); +}); \ No newline at end of file diff --git a/src/__tests__/partner-kpi-tile.test.tsx b/src/__tests__/partner-kpi-tile.test.tsx new file mode 100644 index 000000000..cf7348f9c --- /dev/null +++ b/src/__tests__/partner-kpi-tile.test.tsx @@ -0,0 +1,154 @@ +import { render, screen } from '@testing-library/react'; +import { KpiTile } from 'src/partner-dashboard/components/kpi-tile'; +import { formatAmount, formatAmountWhole, formatCount } from 'src/partner-dashboard/util/format'; + +describe('KpiTile null vs zero', () => { + it('renders an absent gap for null — never as 0', () => { + render( + formatCount(n)} + testId="kpi-new" + />, + ); + + expect(screen.getByTestId('kpi-absent')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-absent')).toHaveTextContent('–'); + expect(screen.queryByTestId('kpi-value')).not.toBeInTheDocument(); + expect(screen.getByTestId('kpi-new')).toHaveAttribute('data-kind', 'absent'); + // Must not claim the value is zero + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); + + it('renders a real zero as 0', () => { + render( + formatCount(n)} + testId="kpi-new-zero" + />, + ); + + expect(screen.getByTestId('kpi-value')).toHaveTextContent('0'); + expect(screen.queryByTestId('kpi-absent')).not.toBeInTheDocument(); + expect(screen.getByTestId('kpi-new-zero')).toHaveAttribute('data-kind', 'value'); + }); +}); + +describe('KpiTile full value on narrow widths (D2)', () => { + it('keeps the full amount string including currency — no truncate/ellipsis class', () => { + const full = formatAmountWhole(245801.35, 'CHF'); + expect(full).toMatch(/CHF/); + + render( +
+ formatAmountWhole(n, 'CHF')} + testId="kpi-volume-narrow" + /> +
, + ); + + const valueEl = screen.getByTestId('kpi-value'); + // Full string must be in the DOM (not ellipsised away) + expect(valueEl).toHaveTextContent(full); + expect(valueEl.textContent).toBe(full); + // No CSS truncation classes that hide the currency + expect(valueEl.className).not.toMatch(/\btruncate\b/); + expect(valueEl.className).not.toMatch(/text-ellipsis/); + expect(valueEl.className).not.toMatch(/overflow-hidden/); + }); + + it('keeps fractional amounts with currency fully present', () => { + const full = formatAmount(109.25, 'CHF'); + render( +
+ formatAmount(n, 'CHF')} + testId="kpi-avg-narrow" + /> +
, + ); + expect(screen.getByTestId('kpi-value').textContent).toBe(full); + }); +}); + +describe('KpiTile caption', () => { + it('renders the caption text with the ${testId}-caption data-testid', () => { + render( + formatCount(n)} + caption="19.2 % of registered users" + testId="kpi-trading-users" + />, + ); + + const caption = screen.getByTestId('kpi-trading-users-caption'); + expect(caption).toBeInTheDocument(); + expect(caption).toHaveTextContent('19.2 % of registered users'); + }); + + it('defaults data-testid to kpi-tile (and caption suffix) when testId is omitted', () => { + render( + formatCount(n)} + caption="secondary line" + />, + ); + + expect(screen.getByTestId('kpi-tile')).toBeInTheDocument(); + expect(screen.getByTestId('kpi-tile-caption')).toHaveTextContent('secondary line'); + }); + + it('treats undefined value as empty kind (not the absent null path)', () => { + render( + formatCount(n)} + testId="kpi-maybe" + />, + ); + expect(screen.getByTestId('kpi-maybe')).toHaveAttribute('data-kind', 'empty'); + // empty still uses the value slot (not kpi-absent) + expect(screen.getByTestId('kpi-value')).toHaveTextContent('–'); + expect(screen.queryByTestId('kpi-absent')).not.toBeInTheDocument(); + }); + + /** + * Guard: when registeredUsers is 0 the caption must never surface NaN, Infinity, + * or a lone "0 %" — those would claim a conversion rate instead of the empty state. + * Mutation target: registeredUsers <= 0 → registeredUsers < 0 (0/0 = NaN would slip through). + */ + it('does not render NaN, Infinity, or a bare 0% for the no-registered-users caption', () => { + render( + formatCount(n)} + caption="No registered users yet" + testId="kpi-trading-users" + />, + ); + + const caption = screen.getByTestId('kpi-trading-users-caption'); + const text = caption.textContent ?? ''; + expect(text).toBe('No registered users yet'); + expect(text).not.toMatch(/NaN/i); + expect(text).not.toMatch(/Infinity/i); + // Bare conversion zero must not appear; the empty-state prose must. + expect(text).not.toMatch(/(^|[^0-9])0\s*%/); + expect(text).not.toMatch(/(^|[^0-9])0%/); + expect(text).toMatch(/No registered users yet/); + }); +}); diff --git a/src/__tests__/partner-page-title.test.tsx b/src/__tests__/partner-page-title.test.tsx new file mode 100644 index 000000000..b01b1a43a --- /dev/null +++ b/src/__tests__/partner-page-title.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { LayoutConfig } from 'src/contexts/layout-config.context'; +import PartnerDashboardView from 'src/partner-dashboard/App'; +import { mockSettingsState } from './helpers/mock-settings-context'; + +const layoutOptionsCalls: LayoutConfig[] = []; + +jest.mock('src/contexts/settings.context', () => ({ + useSettingsContext: () => mockSettingsState, +})); + +jest.mock('react-apexcharts', () => { + return function MockChart() { + return
; + }; +}); + +jest.mock('src/hooks/guarded-api.hook', () => ({ + useGuardedApi: () => ({ call: jest.fn() }), +})); + +// Guard runs for real when the screen mounts — only its dependencies are stubbed. +// Wiring (redirect without role) is covered in partner-dashboard-screen-wiring.test.tsx. +jest.mock('@dfx.swiss/react', () => ({ + UserRole: { + ADMIN: 'Admin', + USER: 'User', + SUPPORT: 'Support', + COMPLIANCE: 'Compliance', + MARKETING: 'Marketing', + }, + useAuthContext: () => ({ session: { role: 'NonCustodialWalletPartner' } }), + useSessionContext: () => ({ isLoggedIn: true }), + useUserContext: () => ({}), +})); + +jest.mock('src/contexts/wallet.context', () => ({ + useWalletContext: () => ({ isInitialized: true }), +})); + +jest.mock('src/hooks/navigation.hook', () => ({ + useNavigation: () => ({ navigate: jest.fn() }), +})); + +jest.mock('src/hooks/layout-config.hook', () => ({ + useLayoutOptions: (config: LayoutConfig) => { + layoutOptionsCalls.push(config); + }, +})); + +import PartnerDashboardScreen from 'src/screens/partner-dashboard.screen'; + +/** + * Page title must appear exactly once: the dashboard header owns it. + * The app layout bar must not also receive a title for this screen — + * that regression is what stacked two identical headlines. + */ +describe('partner dashboard page title appears once', () => { + const originalFixture = process.env.REACT_APP_PARTNER_FIXTURE; + + beforeEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = 'true'; + layoutOptionsCalls.length = 0; + }); + + afterEach(() => { + process.env.REACT_APP_PARTNER_FIXTURE = originalFixture; + }); + + it('does not pass a layout title (avoids stacking under the app bar)', async () => { + render(); + + await waitFor(() => { + expect(layoutOptionsCalls.length).toBeGreaterThan(0); + }); + const last = layoutOptionsCalls[layoutOptionsCalls.length - 1]; + expect(last.title).toBeUndefined(); + expect(last.noPadding).toBe(true); + expect(last.noMaxWidth).toBe(true); + expect(last.backButton).toBe(false); + expect(last.textStart).toBe(true); + + // Drain async fixture load so the suite does not warn about unwrapped updates. + await waitFor(() => { + expect(screen.getByTestId('partner-title')).toBeInTheDocument(); + }); + }); + + it('renders exactly one page title in the dashboard header', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('partner-title')).toBeInTheDocument(); + }); + + // Text match — not testid — so a second headline with another testid still fails. + const titleEls = screen.getAllByText('Non-Custodial Partner Program', { exact: true }); + expect(titleEls).toHaveLength(1); + expect(titleEls[0].tagName).toBe('H1'); + expect(titleEls[0]).toHaveAttribute('data-testid', 'partner-title'); + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(screen.getAllByRole('heading', { name: 'Non-Custodial Partner Program' })).toHaveLength( + 1, + ); + }); +}); diff --git a/src/__tests__/partner-period-controls.test.tsx b/src/__tests__/partner-period-controls.test.tsx new file mode 100644 index 000000000..c0db54f1f --- /dev/null +++ b/src/__tests__/partner-period-controls.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PeriodControls } from 'src/partner-dashboard/components/period-controls'; + +describe('PeriodControls', () => { + it('invokes onPeriodChange with the clicked day count', async () => { + const onPeriodChange = jest.fn(); + const onGranularityChange = jest.fn(); + + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: '90 days' })); + expect(onPeriodChange).toHaveBeenCalledTimes(1); + expect(onPeriodChange).toHaveBeenCalledWith(90); + + await userEvent.click(screen.getByRole('button', { name: '365 days' })); + expect(onPeriodChange).toHaveBeenLastCalledWith(365); + }); + + it('invokes onGranularityChange with the clicked granularity', async () => { + const onPeriodChange = jest.fn(); + const onGranularityChange = jest.fn(); + + render( + , + ); + + await userEvent.click(screen.getByRole('button', { name: 'Week' })); + expect(onGranularityChange).toHaveBeenCalledTimes(1); + expect(onGranularityChange).toHaveBeenCalledWith('Week'); + + await userEvent.click(screen.getByRole('button', { name: 'Month' })); + expect(onGranularityChange).toHaveBeenLastCalledWith('Month'); + }); + + it('marks the active period and granularity via aria-pressed', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '90 days' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '30 days' })).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByRole('button', { name: 'Week' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Day' })).toHaveAttribute('aria-pressed', 'false'); + }); +}); diff --git a/src/__tests__/partner-referral-block.test.tsx b/src/__tests__/partner-referral-block.test.tsx new file mode 100644 index 000000000..86feea432 --- /dev/null +++ b/src/__tests__/partner-referral-block.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { ReferralBlock } from 'src/partner-dashboard/components/referral-block'; +import { PartnerReferral } from 'src/dto/partner-statistic.dto'; + +function referral(overrides: Partial = {}): PartnerReferral { + return { + volume: 42_180.5, + creditEarned: 1_265.4, + creditPaid: 980.0, + creditOpen: 285.4, + currency: 'EUR', + ...overrides, + }; +} + +describe('ReferralBlock — creditOpen is the hero', () => { + it('keeps the outer testid and gives creditOpen a clearly larger type than paid/earned/volume', () => { + render(); + expect(screen.getByTestId('referral-block')).toBeInTheDocument(); + + const hero = screen.getByTestId('referral-credit-open'); + // Number only — currency is the sibling span (avoids "EUR EUR" from formatAmount + span). + expect(hero).toHaveTextContent('285.40'); + expect(hero).not.toHaveTextContent('EUR'); + // Hero uses a materially larger type scale than the supporting figures + expect(hero.className).toMatch(/text-2xl|text-3xl/); + expect(hero.className).toContain('tabular-nums'); + + const earned = screen.getByTestId('referral-credit-earned'); + const paid = screen.getByTestId('referral-credit-paid'); + const volume = screen.getByTestId('referral-volume'); + for (const el of [earned, paid, volume]) { + expect(el.className).not.toMatch(/text-2xl|text-3xl/); + } + }); + + it('repeats the EUR currency directly next to the hero number, plus the header badge', () => { + render(); + expect(screen.getByTestId('referral-currency-badge')).toHaveTextContent('EUR'); + // The hero figure's own container also carries a currency marker beside it + const heroBlock = screen.getByTestId('referral-credit-open').closest('div'); + expect(heroBlock).toHaveTextContent('EUR'); + }); +}); + +describe('ReferralBlock — earned/paid/open as one split bar', () => { + it('sizes the paid and open segments proportionally to their share of earned', () => { + render(); + const paidSegment = screen.getByTestId('referral-split-paid'); + const openSegment = screen.getByTestId('referral-split-open'); + expect(paidSegment.style.width).toBe('75%'); + expect(openSegment.style.width).toBe('25%'); + }); + + it('labels both segments directly with their exact amounts, not just the bar', () => { + render(); + expect(screen.getByTestId('referral-credit-paid')).toHaveTextContent('750.00 EUR'); + // Hero figure is currency-free; unit sits in the adjacent span + expect(screen.getByTestId('referral-credit-open')).toHaveTextContent('250.00'); + expect(screen.getByTestId('referral-credit-open').closest('div')).toHaveTextContent('EUR'); + expect(screen.getByTestId('referral-credit-earned')).toHaveTextContent('1,000.00 EUR'); + }); + + it('renders no segments (a flat empty track) when nothing has been earned yet', () => { + render(); + expect(screen.queryByTestId('referral-split-paid')).not.toBeInTheDocument(); + expect(screen.queryByTestId('referral-split-open')).not.toBeInTheDocument(); + expect(screen.getByTestId('referral-credit-split')).toBeInTheDocument(); + }); + + it('never uses a status (green/red) colour for the paid/open split', () => { + render(); + // jsdom's CSS engine drops `background-color: var(...)` from the serialized style + // attribute entirely (a jsdom/cssstyle limitation, not a real-browser behaviour), so the + // exact token is asserted via a plain data attribute instead of parsing computed CSS. + const paidToken = screen.getByTestId('referral-split-paid').getAttribute('data-fill-token') ?? ''; + const openToken = screen.getByTestId('referral-split-open').getAttribute('data-fill-token') ?? ''; + const forbidden = /#16a34a|#dc2626|#27ae60|#eab308|green|red/i; + expect(paidToken).not.toMatch(forbidden); + expect(openToken).not.toMatch(forbidden); + // Only theme-neutral tokens back the two segments + expect(paidToken).toBe('var(--text-secondary)'); + expect(openToken).toBe('var(--text)'); + }); +}); + +describe('ReferralBlock — volume is context, not a credit figure', () => { + it('renders volume in its own row, separated from the credit numbers', () => { + render(); + const volumeRow = screen.getByTestId('referral-volume-row'); + expect(volumeRow).toContainElement(screen.getByTestId('referral-volume')); + // The credit split lives in a different container than the volume row + const split = screen.getByTestId('referral-credit-split'); + expect(volumeRow).not.toContainElement(split); + expect(split).not.toContainElement(volumeRow); + }); +}); diff --git a/src/__tests__/partner-series.test.ts b/src/__tests__/partner-series.test.ts new file mode 100644 index 000000000..c9dfc76ce --- /dev/null +++ b/src/__tests__/partner-series.test.ts @@ -0,0 +1,213 @@ +import { PartnerTimelineBucket } from 'src/dto/partner-statistic.dto'; +import { buildPartnerTimelineFixture } from 'src/partner-dashboard/fixtures/partner-statistic.fixture'; +import { + hasActivity, + rankNamedVolumes, + sequentialColor, + timelineSeries, +} from 'src/partner-dashboard/util/series'; + +function bucket( + partial: Partial & Pick, +): PartnerTimelineBucket { + return { + volume: { buy: 0, sell: 0, swap: 0 }, + transactions: { buy: 0, sell: 0, swap: 0 }, + partial: false, + ...partial, + }; +} + +describe('partner timeline series', () => { + it('never returns a gap: every point is a real number, a day with no activity is the zero point', () => { + const buckets: PartnerTimelineBucket[] = [ + bucket({ + date: '2026-06-01T00:00:00.000Z', + volume: { buy: 100, sell: 10, swap: 5 }, + transactions: { buy: 2, sell: 1, swap: 1 }, + }), + bucket({ + date: '2026-06-02T00:00:00.000Z', + volume: { buy: 0, sell: 0, swap: 0 }, + transactions: { buy: 0, sell: 0, swap: 0 }, + }), + ]; + + const series = timelineSeries(buckets, 'volume', 'buy'); + expect(series).toHaveLength(2); + expect(series[0][1]).toBe(100); + // A day with no activity is the zero point on the curve — never a hole + expect(series[1][1]).toBe(0); + // The return type itself forbids null (Array<[number, number]>) — assert every + // value really is a finite number at runtime too, not just at compile time. + for (const field of ['volume', 'transactions'] as const) { + for (const direction of ['buy', 'sell', 'swap'] as const) { + for (const [, y] of timelineSeries(buckets, field, direction)) { + expect(typeof y).toBe('number'); + expect(Number.isFinite(y)).toBe(true); + } + } + } + }); + + it('passes every non-zero value through unchanged, including thin single-digit days (no withholding by magnitude)', () => { + // Deliberately small, non-zero counts — the exact shape a k-anonymity threshold + // used to null out. Every one of these must reach the series untouched. + const buckets: PartnerTimelineBucket[] = [ + bucket({ + date: '2026-06-01T00:00:00.000Z', + volume: { buy: 3, sell: 1, swap: 4 }, + transactions: { buy: 1, sell: 2, swap: 3 }, + }), + ]; + + expect(timelineSeries(buckets, 'volume', 'buy')[0][1]).toBe(3); + expect(timelineSeries(buckets, 'volume', 'sell')[0][1]).toBe(1); + expect(timelineSeries(buckets, 'volume', 'swap')[0][1]).toBe(4); + expect(timelineSeries(buckets, 'transactions', 'buy')[0][1]).toBe(1); + expect(timelineSeries(buckets, 'transactions', 'sell')[0][1]).toBe(2); + expect(timelineSeries(buckets, 'transactions', 'swap')[0][1]).toBe(3); + }); + + it('fixture timeline still carries the partial flag from the API contract (data, not UI)', () => { + // The dashboard renders nothing from `partial` anymore, but the API still sends it — + // the field must survive on the data model even though no component reads it. + const tl = buildPartnerTimelineFixture('Day'); + expect(tl.buckets[0].partial).toBe(true); + expect(tl.buckets[tl.buckets.length - 1].partial).toBe(true); + }); + + it('fixture timeline bucket count grows with the requested period (30 / 90 / 365)', () => { + const end = '2026-06-30T23:59:59.000Z'; + const rangeFor = (days: number) => { + const from = new Date(end); + from.setUTCDate(from.getUTCDate() - (days - 1)); + from.setUTCHours(0, 0, 0, 0); + return { from: from.toISOString(), to: end }; + }; + const d30 = buildPartnerTimelineFixture('Day', rangeFor(30)); + const d90 = buildPartnerTimelineFixture('Day', rangeFor(90)); + const d365 = buildPartnerTimelineFixture('Day', rangeFor(365)); + expect(d30.buckets.length).toBe(30); + expect(d90.buckets.length).toBe(90); + expect(d365.buckets.length).toBe(365); + expect(d30.buckets.length).toBeLessThan(d90.buckets.length); + expect(d90.buckets.length).toBeLessThan(d365.buckets.length); + // Acceptance fixture preserved on the default 30-day day series + expect(d30.buckets[5].volume).toEqual({ buy: 0, sell: 0, swap: 0 }); + // Every other bucket carries real, non-zero data — the fixture is real throughout + expect(d30.buckets[12].volume.buy).toBeGreaterThan(0); + }); + + it('fixture timeline Month granularity steps by ~30 days (fewer buckets than Day)', () => { + const end = '2026-06-30T23:59:59.000Z'; + const from = new Date(end); + from.setUTCDate(from.getUTCDate() - 364); + from.setUTCHours(0, 0, 0, 0); + const range = { from: from.toISOString(), to: end }; + const month = buildPartnerTimelineFixture('Month', range); + const day = buildPartnerTimelineFixture('Day', range); + expect(month.granularity).toBe('Month'); + // 365 days / 30 step ≈ 13 buckets — far fewer than daily + expect(month.buckets.length).toBeLessThan(day.buckets.length); + expect(month.buckets.length).toBeGreaterThanOrEqual(12); + expect(month.buckets.length).toBeLessThanOrEqual(13); + }); + + it('buildPartnerTimelineFixture() without args uses Day default and default range', () => { + const tl = buildPartnerTimelineFixture(); + expect(tl.granularity).toBe('Day'); + expect(tl.buckets.length).toBe(30); + }); + + it('coarse granularity with a short window yields bucketCount < 3 (zeroIdx = -1 branch)', () => { + // 7-day window + Month step (30) → single bucket → zeroIdx falls to -1 + const tl = buildPartnerTimelineFixture('Month', { + from: '2026-06-24T00:00:00.000Z', + to: '2026-06-30T23:59:59.000Z', + }); + expect(tl.buckets.length).toBe(1); + // The only bucket is still partial (edge), not forced to the zero marker + expect(tl.buckets[0].partial).toBe(true); + }); + + it('ranks named volumes descending', () => { + const ranked = rankNamedVolumes([ + { name: 'B', volume: 10, transactions: 2 }, + { name: 'C', volume: 50, transactions: 5 }, + { name: 'D', volume: 0, transactions: 0 }, + ]); + expect(ranked.map((r) => r.name)).toEqual(['C', 'B', 'D']); + }); + + it('aggregates the tail as Other (caller label) and re-sorts so a large aggregate ranks by volume', () => { + // 15 rows, maxItems 5 → head of 4 + one "Other" summing the remaining 11 + const rows = Array.from({ length: 15 }, (_, i) => ({ + name: `Asset-${i}`, + volume: 100 - i, + transactions: i + 1, + })); + const ranked = rankNamedVolumes(rows, 5, 'Other'); + expect(ranked).toHaveLength(5); + // Tail sum is large enough to outrank some head rows after re-sort + const other = ranked.find((r) => r.name === 'Other'); + expect(other).toBeDefined(); + const tail = [...rows].sort((a, b) => b.volume - a.volume).slice(4); + expect(other?.volume).toBe(tail.reduce((s, r) => s + r.volume, 0)); + expect(other?.transactions).toBe(tail.reduce((s, r) => s + r.transactions, 0)); + // Full list is volume-descending (Other not stuck at the end by append order) + for (let i = 1; i < ranked.length; i++) { + expect(ranked[i - 1].volume).toBeGreaterThanOrEqual(ranked[i].volume); + } + }); + + it('returns the full sorted list when length equals maxItems (no Other row)', () => { + const rows = Array.from({ length: 3 }, (_, i) => ({ + name: `R${i}`, + volume: i + 1, + transactions: 1, + })); + const ranked = rankNamedVolumes(rows, 3, 'Other'); + expect(ranked).toHaveLength(3); + expect(ranked.map((r) => r.name)).not.toContain('Other'); + }); + + it('uses a large Other aggregate as maxVolume scale after re-sort (not stuck last)', () => { + // 30 equal rows → Other sum dwarfs each single head row + const rows = Array.from({ length: 30 }, (_, i) => ({ + name: `R${i}`, + volume: 10, + transactions: 1, + })); + const ranked = rankNamedVolumes(rows, 12, 'Other'); + expect(ranked[0].name).toBe('Other'); + expect(ranked[0].volume).toBe(19 * 10); + }); + + it('sequentialColor yields distinct colours for consecutive indices (cycles contrast-safe palette)', () => { + const n = 4; + const colors = Array.from({ length: n }, (_, i) => sequentialColor(i, n, 'light')); + expect(new Set(colors).size).toBe(n); + // Cycle: index n reuses palette[0] + expect(sequentialColor(n, n + 1, 'light')).toBe(sequentialColor(0, n + 1, 'light')); + }); + + it('hasActivity drops only rows with zero volume AND zero transactions', () => { + expect(hasActivity({ volume: 0, transactions: 0 })).toBe(false); + // Either side alone being non-zero is still real activity + expect(hasActivity({ volume: 0, transactions: 5 })).toBe(true); + expect(hasActivity({ volume: 42, transactions: 0 })).toBe(true); + expect(hasActivity({ volume: 42, transactions: 5 })).toBe(true); + }); + + it('sequentialColor defaults theme to dark and returns the first shade when total <= 1', () => { + // Default-arg branch: omit theme → dark palette + const defaulted = sequentialColor(0, 5); + const explicitDark = sequentialColor(0, 5, 'dark'); + expect(defaulted).toBe(explicitDark); + + // total <= 1 → always palette[0] + expect(sequentialColor(0, 1, 'light')).toBe(sequentialColor(0, 1, 'light')); + expect(sequentialColor(99, 0, 'light')).toBe(sequentialColor(0, 1, 'light')); + }); +}); diff --git a/src/__tests__/partner-theme-ssr-runtime.test.ts b/src/__tests__/partner-theme-ssr-runtime.test.ts new file mode 100644 index 000000000..5abfaa7a8 --- /dev/null +++ b/src/__tests__/partner-theme-ssr-runtime.test.ts @@ -0,0 +1,53 @@ +import { + readCssVar, + readStoredTheme, + readThemeCssVar, + resolveInitialTheme, +} from 'src/partner-dashboard/util/theme'; + +/** + * Covers SSR guards (`typeof window/document === 'undefined'`) that are + * unreachable under a normal jsdom lifecycle. We temporarily remove the + * globals at call time so free-var `typeof` checks take the SSR arm, then + * restore the original property descriptors. + */ +describe('partner theme SSR guards via runtime global deletion', () => { + it('readStoredTheme returns null when window is removed at call time', () => { + const g = globalThis as typeof globalThis & { window?: Window & typeof globalThis }; + const descriptor = Object.getOwnPropertyDescriptor(g, 'window'); + expect(descriptor?.configurable).toBe(true); + + // @ts-expect-error test-only: remove window so typeof window === 'undefined' + delete g.window; + try { + expect(typeof window).toBe('undefined'); + expect(readStoredTheme()).toBeNull(); + expect(resolveInitialTheme()).toBe('light'); + } finally { + if (descriptor) { + Object.defineProperty(g, 'window', descriptor); + } + } + expect(typeof window).not.toBe('undefined'); + }); + + it('readCssVar / readThemeCssVar return empty when document is removed', () => { + const g = globalThis as typeof globalThis & { document?: Document }; + const descriptor = Object.getOwnPropertyDescriptor(g, 'document'); + expect(descriptor?.configurable).toBe(true); + + // @ts-expect-error test-only + delete g.document; + try { + // el omitted → document branch is null → early return '' + expect(readCssVar('--text')).toBe(''); + expect(readThemeCssVar('--text', 'dark')).toBe(''); + expect(readThemeCssVar('--text', 'light')).toBe(''); + } finally { + if (descriptor) { + Object.defineProperty(g, 'document', descriptor); + } + } + expect(typeof document).not.toBe('undefined'); + }); +}); diff --git a/src/__tests__/partner-theme.test.ts b/src/__tests__/partner-theme.test.ts new file mode 100644 index 000000000..18587a9d3 --- /dev/null +++ b/src/__tests__/partner-theme.test.ts @@ -0,0 +1,149 @@ +import { act, renderHook } from '@testing-library/react'; +import { + PARTNER_THEME_STORAGE_KEY, + persistTheme, + readCssVar, + readStoredTheme, + readThemeCssVar, + resolveInitialTheme, + themeClassName, + usePartnerTheme, +} from 'src/partner-dashboard/util/theme'; + +describe('partner theme helpers', () => { + beforeEach(() => { + window.localStorage.removeItem(PARTNER_THEME_STORAGE_KEY); + document.getElementById('partner-dashboard-root')?.remove(); + }); + + afterEach(() => { + window.localStorage.removeItem(PARTNER_THEME_STORAGE_KEY); + document.getElementById('partner-dashboard-root')?.remove(); + }); + + it('readStoredTheme returns null for missing or invalid values', () => { + expect(readStoredTheme()).toBeNull(); + window.localStorage.setItem(PARTNER_THEME_STORAGE_KEY, 'purple'); + expect(readStoredTheme()).toBeNull(); + }); + + it('readStoredTheme returns light/dark when stored', () => { + window.localStorage.setItem(PARTNER_THEME_STORAGE_KEY, 'dark'); + expect(readStoredTheme()).toBe('dark'); + window.localStorage.setItem(PARTNER_THEME_STORAGE_KEY, 'light'); + expect(readStoredTheme()).toBe('light'); + }); + + it('resolveInitialTheme defaults to light when nothing is stored', () => { + expect(resolveInitialTheme()).toBe('light'); + }); + + it('persistTheme writes the value and setTheme/toggleTheme round-trip through the hook', () => { + const { result } = renderHook(() => usePartnerTheme()); + expect(result.current.theme).toBe('light'); + + act(() => { + result.current.setTheme('dark'); + }); + expect(result.current.theme).toBe('dark'); + expect(window.localStorage.getItem(PARTNER_THEME_STORAGE_KEY)).toBe('dark'); + + act(() => { + result.current.toggleTheme(); + }); + expect(result.current.theme).toBe('light'); + expect(window.localStorage.getItem(PARTNER_THEME_STORAGE_KEY)).toBe('light'); + + act(() => { + result.current.toggleTheme(); + }); + expect(result.current.theme).toBe('dark'); + }); + + it('persistTheme swallows localStorage write errors', () => { + const setItem = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + expect(() => persistTheme('dark')).not.toThrow(); + setItem.mockRestore(); + }); + + it('readStoredTheme swallows localStorage read errors', () => { + const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + expect(readStoredTheme()).toBeNull(); + getItem.mockRestore(); + }); + + it('themeClassName maps light/dark', () => { + expect(themeClassName('light')).toBe('theme-light'); + expect(themeClassName('dark')).toBe('theme-dark'); + }); + + it('readCssVar reads from an explicit element', () => { + const el = document.createElement('div'); + el.style.setProperty('--text', ' rgb(1, 2, 3) '); + document.body.appendChild(el); + expect(readCssVar('--text', el)).toBe('rgb(1, 2, 3)'); + el.remove(); + }); + + it('readCssVar falls back to partner-dashboard-root then documentElement', () => { + const root = document.createElement('div'); + root.id = 'partner-dashboard-root'; + root.style.setProperty('--border', '#abc'); + document.body.appendChild(root); + expect(readCssVar('--border')).toBe('#abc'); + root.remove(); + + // No root → documentElement (may be empty string if unset) + const fromDoc = readCssVar('--nonexistent-partner-var'); + expect(typeof fromDoc).toBe('string'); + }); + + it('readThemeCssVar uses a probe when the live root theme does not match', () => { + const root = document.createElement('div'); + root.id = 'partner-dashboard-root'; + root.className = 'theme-dark'; + document.body.appendChild(root); + + // Request light while root is dark → probe path (still returns a string) + const value = readThemeCssVar('--text', 'light'); + expect(typeof value).toBe('string'); + root.remove(); + }); + + it('readCssVar returns empty string when getComputedStyle is unavailable', () => { + const original = window.getComputedStyle; + Object.defineProperty(window, 'getComputedStyle', { + configurable: true, + value: undefined, + }); + try { + const el = document.createElement('div'); + expect(readCssVar('--text', el)).toBe(''); + } finally { + Object.defineProperty(window, 'getComputedStyle', { + configurable: true, + value: original, + }); + } + }); + + it('readThemeCssVar returns empty string when getComputedStyle is unavailable', () => { + const original = window.getComputedStyle; + Object.defineProperty(window, 'getComputedStyle', { + configurable: true, + value: undefined, + }); + try { + expect(readThemeCssVar('--text', 'dark')).toBe(''); + } finally { + Object.defineProperty(window, 'getComputedStyle', { + configurable: true, + value: original, + }); + } + }); +}); diff --git a/src/__tests__/partner-timeline-axis.test.ts b/src/__tests__/partner-timeline-axis.test.ts new file mode 100644 index 000000000..0f1662718 --- /dev/null +++ b/src/__tests__/partner-timeline-axis.test.ts @@ -0,0 +1,176 @@ +import { buildPartnerTimelineFixture } from 'src/partner-dashboard/fixtures/partner-statistic.fixture'; +import { + buildTimelineXAxis, + formatTimelineTick, + selectTimelineTickIndices, + targetTimelineTickCount, + timelineSeriesValues, +} from 'src/partner-dashboard/util/timeline-axis'; + +describe('timeline X-axis tick strategy', () => { + it('targets fewer labels on narrow widths and ~6–8 on desktop', () => { + expect(targetTimelineTickCount(320)).toBe(4); + expect(targetTimelineTickCount(500)).toBe(5); + expect(targetTimelineTickCount(800)).toBe(7); + expect(targetTimelineTickCount(1200)).toBe(8); + // Default desktop card width + expect(targetTimelineTickCount()).toBe(8); + }); + + it('always includes first and last index and stays within maxTicks', () => { + for (const n of [2, 7, 30, 90, 365]) { + const max = targetTimelineTickCount(960); + const indices = selectTimelineTickIndices(n, max); + expect(indices[0]).toBe(0); + expect(indices[indices.length - 1]).toBe(n - 1); + expect(indices.length).toBeLessThanOrEqual(Math.min(max, n)); + expect(indices.length).toBeGreaterThanOrEqual(Math.min(2, n)); + // Sorted unique + expect(indices).toEqual([...new Set(indices)].sort((a, b) => a - b)); + } + }); + + it('keeps label count independent of bucket count (30 / 90 / 365 day)', () => { + const max = 8; + const i30 = selectTimelineTickIndices(30, max); + const i90 = selectTimelineTickIndices(90, max); + const i365 = selectTimelineTickIndices(365, max); + expect(i30.length).toBeLessThanOrEqual(max); + expect(i90.length).toBeLessThanOrEqual(max); + expect(i365.length).toBeLessThanOrEqual(max); + // Same strategy → same count for long series + expect(i90.length).toBe(i365.length); + expect(i30.length).toBe(i90.length); + }); + + it('formats Day / Week / Month via locale (en + de)', () => { + const iso = '2026-03-15T00:00:00.000Z'; + const enDay = formatTimelineTick(iso, 'Day', 'en-US'); + const deDay = formatTimelineTick(iso, 'Day', 'de-CH'); + expect(enDay.length).toBeGreaterThan(0); + expect(deDay.length).toBeGreaterThan(0); + // Month includes a year digit + const enMonth = formatTimelineTick(iso, 'Month', 'en-US'); + expect(enMonth).toMatch(/26|2026/); + const enWeek = formatTimelineTick(iso, 'Week', 'en-US'); + expect(enWeek.length).toBeGreaterThan(0); + }); + + it('buildTimelineXAxis labels only selected indices; first and last non-empty', () => { + const tl = buildPartnerTimelineFixture('Day', { + from: '2026-01-01T00:00:00.000Z', + to: '2026-03-31T23:59:59.000Z', + }); + const axis = buildTimelineXAxis({ + buckets: tl.buckets, + granularity: 'Day', + locale: 'en-US', + axisColor: '#8a99b7', + chartWidthPx: 960, + }); + expect(axis.xaxis?.type).toBe('category'); + expect(axis.xaxis?.categories).toHaveLength(tl.buckets.length); + expect(axis.xaxis?.axisTicks?.show).toBe(false); + expect(axis.grid?.xaxis?.lines?.show).toBe(false); + + // Display labels are precomputed (overwriteCategories) — Apex draws these. + const labels = axis.xaxis?.overwriteCategories as string[]; + expect(labels).toHaveLength(tl.buckets.length); + const nonEmpty = labels.filter((l) => l !== ''); + expect(nonEmpty.length).toBeGreaterThanOrEqual(2); + expect(nonEmpty.length).toBeLessThanOrEqual(8); + expect(labels[0]).not.toBe(''); + expect(labels[labels.length - 1]).not.toBe(''); + // Mid non-selected stay blank when series is longer than max ticks + if (tl.buckets.length > 8) { + expect(labels.some((l) => l === '')).toBe(true); + } + }); + + it('same buckets + granularity + width → identical axis config for both charts', () => { + const tl = buildPartnerTimelineFixture('Week', { + from: '2025-07-01T00:00:00.000Z', + to: '2026-06-30T23:59:59.000Z', + }); + const a = buildTimelineXAxis({ + buckets: tl.buckets, + granularity: tl.granularity, + locale: 'de-CH', + axisColor: '#8a99b7', + chartWidthPx: 800, + }); + const b = buildTimelineXAxis({ + buckets: tl.buckets, + granularity: tl.granularity, + locale: 'de-CH', + axisColor: '#8a99b7', + chartWidthPx: 800, + }); + expect(a.xaxis?.categories).toEqual(b.xaxis?.categories); + expect(a.xaxis?.overwriteCategories).toEqual(b.xaxis?.overwriteCategories); + }); + + it('timelineSeriesValues strips timestamps for category series data', () => { + expect( + timelineSeriesValues([ + [1, 10], + [2, 3], + [3, 0], + ]), + ).toEqual([10, 3, 0]); + }); + + it('tick strategy is required: blanking the selector leaves only endpoints when maxTicks is 2', () => { + // Counter-probe helper: strategy must enforce first+last and cap mid points. + // If selectTimelineTickIndices were a no-op returning every index, this fails. + const indices = selectTimelineTickIndices(90, 7); + expect(indices.length).toBeLessThanOrEqual(7); + expect(indices).toContain(0); + expect(indices).toContain(89); + // Not every day labeled + expect(indices.length).toBeLessThan(90); + }); + + it('xaxis labels.formatter formats selected ISO categories and blanks unselected ones', () => { + const buckets = Array.from({ length: 10 }, (_, i) => ({ + date: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00.000Z`, + volume: { buy: 1, sell: 0, swap: 0 }, + transactions: { buy: 1, sell: 0, swap: 0 }, + partial: false, + })); + const axis = buildTimelineXAxis({ + buckets, + granularity: 'Day', + locale: 'en-US', + axisColor: '#8a99b7', + chartWidthPx: 960, + }); + const formatter = axis.xaxis?.labels?.formatter; + expect(formatter).toBeDefined(); + if (!formatter) return; + + const categories = axis.xaxis?.categories as string[]; + const overwrite = axis.xaxis?.overwriteCategories as string[]; + // First index is always selected → non-empty formatted label + expect(formatter(categories[0])).toBe(overwrite[0]); + expect(formatter(categories[0])).toBe( + formatTimelineTick(categories[0], 'Day', 'en-US'), + ); + // Find an unselected mid bucket (empty overwrite) + const blankIdx = overwrite.findIndex((l, i) => l === '' && i > 0 && i < overwrite.length - 1); + expect(blankIdx).toBeGreaterThan(0); + expect(formatter(categories[blankIdx])).toBe(''); + // Unknown value (already-overwritten label path) is returned as-is + expect(formatter('already-formatted')).toBe('already-formatted'); + }); + + it('formatTimelineTick returns empty string for invalid dates', () => { + expect(formatTimelineTick('not-a-date', 'Day', 'en-US')).toBe(''); + }); + + it('selectTimelineTickIndices handles empty and single-bucket series', () => { + expect(selectTimelineTickIndices(0, 8)).toEqual([]); + expect(selectTimelineTickIndices(1, 8)).toEqual([0]); + expect(selectTimelineTickIndices(5, 2)).toEqual([0, 4]); + }); +}); diff --git a/src/__tests__/partner-timeline-chart.test.tsx b/src/__tests__/partner-timeline-chart.test.tsx new file mode 100644 index 000000000..a59d47437 --- /dev/null +++ b/src/__tests__/partner-timeline-chart.test.tsx @@ -0,0 +1,366 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { VolumeTimeChart } from 'src/partner-dashboard/components/volume-time-chart'; +import { TransactionsTimeChart } from 'src/partner-dashboard/components/transactions-time-chart'; +import { PartnerTimeline } from 'src/dto/partner-statistic.dto'; +import { ABSENT_LABEL, formatAmount, formatCount } from 'src/partner-dashboard/util/format'; +import { timelineSeries } from 'src/partner-dashboard/util/series'; + +jest.mock('src/partner-dashboard/util/chart-theme', () => { + const actual = jest.requireActual('src/partner-dashboard/util/chart-theme') as typeof import('src/partner-dashboard/util/chart-theme'); + return { + ...actual, + // Force missing axis color so the chart's `?? ''` fallback branch is exercised. + baseChartOptions: (theme: 'light' | 'dark') => { + const base = actual.baseChartOptions(theme); + return { + ...base, + xaxis: { + ...base.xaxis, + labels: { + ...base.xaxis?.labels, + style: { + ...base.xaxis?.labels?.style, + colors: undefined, + }, + }, + }, + }; + }, + }; +}); + +jest.mock('react-apexcharts', () => { + return function MockChart(props: { + series?: Array<{ name: string; data: Array }>; + options?: { + xaxis?: { + type?: string; + categories?: string[]; + overwriteCategories?: string[]; + labels?: { formatter?: (value: string, ts?: number, opts?: { i?: number }) => string }; + }; + yaxis?: { + labels?: { formatter?: (val: number) => string }; + }; + tooltip?: { + x?: { formatter?: (val: number, opts?: { dataPointIndex?: number }) => string }; + y?: { formatter?: (val: number) => string }; + }; + annotations?: { + xaxis?: Array<{ + x?: number | string; + x2?: number | string; + label?: { text?: string }; + fillColor?: string; + }>; + }; + }; + }) { + const xAnns = props.options?.annotations?.xaxis ?? []; + const overwrite = props.options?.xaxis?.overwriteCategories; + const categories = props.options?.xaxis?.categories ?? []; + const formatter = props.options?.xaxis?.labels?.formatter; + const yFormatter = props.options?.yaxis?.labels?.formatter; + const tooltipX = props.options?.tooltip?.x?.formatter; + const tooltipY = props.options?.tooltip?.y?.formatter; + const tickLabels = + overwrite ?? + categories.map((cat, i) => (formatter ? formatter(cat, undefined, { i }) : cat)); + return ( +
+ +
+ {tickLabels.map((label, i) => ( + + ))} +
+ {(props.series ?? []).map((s) => ( +
+ {s.data.map((point, i) => { + const y = Array.isArray(point) ? point[1] : point; + return ( + + ); + })} +
+ ))} +
+ {xAnns.map((ann, i) => ( + + ))} +
+ {/* Expose Apex formatters so tests can assert return values with real inputs. */} + {yFormatter && ( +
+ + + + +
+ )} + {tooltipY && ( +
+ + + +
+ )} + {tooltipX && ( +
+ + + +
+ )} +
+ ); + }; +}); + +const timeline: PartnerTimeline = { + period: { from: '2026-06-01T00:00:00.000Z', to: '2026-06-03T23:59:59.000Z' }, + currency: 'CHF', + granularity: 'Day', + buckets: [ + { + date: '2026-06-01T00:00:00.000Z', + volume: { buy: 100, sell: 10, swap: 5 }, + transactions: { buy: 4, sell: 1, swap: 1 }, + partial: true, + }, + { + // Deliberately thin — a day with almost no activity, never a hole. + date: '2026-06-02T00:00:00.000Z', + volume: { buy: 3, sell: 1, swap: 2 }, + transactions: { buy: 1, sell: 1, swap: 1 }, + partial: false, + }, + { + date: '2026-06-03T00:00:00.000Z', + volume: { buy: 0, sell: 0, swap: 0 }, + transactions: { buy: 0, sell: 0, swap: 0 }, + partial: false, + }, + ], + meta: {}, +}; + +describe('timeline charts — thin day is a low point, no-activity day is the zero point', () => { + it('never returns a gap for a thin day; a no-activity day is exactly 0', () => { + const series = timelineSeries(timeline.buckets, 'volume', 'buy'); + expect(series[0][1]).toBe(100); + // Thin, non-zero day — a low point on the curve, never bridged/invented, never null + expect(series[1][1]).toBe(3); + // No-activity day is the zero point — not a hole either + expect(series[2][1]).toBe(0); + }); + + it('renders the thin day as its own low point in the chart series, not a gap', () => { + render(); + expect(screen.getByTestId('volume-time-chart')).toBeInTheDocument(); + expect(screen.getByTestId('point-Buy-1')).toHaveAttribute('data-value', '3'); + expect(screen.getByTestId('point-Buy-2')).toHaveAttribute('data-value', '0'); + // No chart annotations are emitted anymore (bands are gone; partial edges + // are chips-only) + expect(screen.queryAllByTestId('xaxis-annotation')).toHaveLength(0); + }); + + it('pins Sell and Swap series to their own direction values (not Buy)', () => { + // Catches timelineSeries(..., 'volume', 'sell') → 'buy' and the same for swap. + render(); + expect(screen.getByTestId('point-Sell-0')).toHaveAttribute('data-value', '10'); + expect(screen.getByTestId('point-Sell-1')).toHaveAttribute('data-value', '1'); + expect(screen.getByTestId('point-Swap-0')).toHaveAttribute('data-value', '5'); + expect(screen.getByTestId('point-Swap-1')).toHaveAttribute('data-value', '2'); + // Buy must stay distinct from Sell on day 0 + expect(screen.getByTestId('point-Buy-0')).toHaveAttribute('data-value', '100'); + }); + + it('table never shows the absent placeholder — every day is a real value', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Show as table' })); + expect(screen.queryByText('–')).not.toBeInTheDocument(); + }); + + it('table row for a thin (small non-zero) day keeps its exact value — never dropped or zeroed', () => { + // Deliberately small, non-zero — the exact shape a k-anonymity threshold used to withhold. + const thinTimeline: PartnerTimeline = { + period: { from: '2026-06-10T00:00:00.000Z', to: '2026-06-10T23:59:59.000Z' }, + currency: 'CHF', + granularity: 'Day', + buckets: [ + { + date: '2026-06-10T00:00:00.000Z', + volume: { buy: 3, sell: 1, swap: 4 }, + transactions: { buy: 1, sell: 2, swap: 3 }, + partial: false, + }, + ], + meta: {}, + }; + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Show as table' })); + expect(screen.getByText(formatAmount(3, 'CHF', 2))).toBeInTheDocument(); + expect(screen.getByText(formatAmount(1, 'CHF', 2))).toBeInTheDocument(); + expect(screen.getByText(formatAmount(4, 'CHF', 2))).toBeInTheDocument(); + // Row must not be represented as absent/withheld + expect(screen.queryByText('–')).not.toBeInTheDocument(); + }); + + it('renders transactions chart as its own chart (not a second Y-axis)', () => { + render(); + expect(screen.getByTestId('transactions-time-chart')).toBeInTheDocument(); + expect(screen.getByText('Transactions over time')).toBeInTheDocument(); + // The thin day (1 transaction) is a real low point, never a gap + expect(screen.getByTestId('point-Buy-1')).toHaveAttribute('data-value', '1'); + }); + + it('uses the same shared tick labels on volume and transactions charts', () => { + const { unmount } = render(); + expect(screen.getByTestId('xaxis-type')).toHaveAttribute('data-type', 'category'); + const volumeLabels = screen + .getAllByTestId('xaxis-tick-label') + .map((el) => el.getAttribute('data-label') ?? ''); + unmount(); + + render(); + expect(screen.getByTestId('xaxis-type')).toHaveAttribute('data-type', 'category'); + const txLabels = screen + .getAllByTestId('xaxis-tick-label') + .map((el) => el.getAttribute('data-label') ?? ''); + expect(txLabels).toEqual(volumeLabels); + // First and last buckets always labeled + expect(volumeLabels[0]).not.toBe(''); + expect(volumeLabels[volumeLabels.length - 1]).not.toBe(''); + }); + + it('renders nothing from `partial` even though the first bucket carries it (owner: marking is gone)', () => { + render(); + expect(timeline.buckets[0].partial).toBe(true); + expect(screen.queryByTestId('partial-legend')).not.toBeInTheDocument(); + expect(screen.queryByTestId('partial-markers')).not.toBeInTheDocument(); + expect(screen.queryByTestId('partial-marker')).not.toBeInTheDocument(); + expect(screen.queryByText(/incomplete/i)).not.toBeInTheDocument(); + }); + + it('table has no "Note" column and no incomplete suffix on the partial bucket', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Show as table' })); + expect(screen.queryByText('Note')).not.toBeInTheDocument(); + expect(screen.queryByText(/incomplete/i)).not.toBeInTheDocument(); + }); +}); + +describe('volume chart Apex formatters (y-axis k-rule + tooltip)', () => { + it('formats y-axis: 1000 → "1k", values under 1000 stay plain integers', () => { + render(); + + expect(screen.getByTestId('y-fmt-1000')).toHaveAttribute('data-result', '1k'); + expect(screen.getByTestId('y-fmt-2500')).toHaveAttribute('data-result', '3k'); + expect(screen.getByTestId('y-fmt-999')).toHaveAttribute('data-result', '999'); + expect(screen.getByTestId('y-fmt-0')).toHaveAttribute('data-result', '0'); + }); + + it('tooltip y: NaN → ABSENT_LABEL; finite values use formatAmount', () => { + render(); + + expect(screen.getByTestId('tooltip-y-nan')).toHaveAttribute('data-result', ABSENT_LABEL); + expect(screen.getByTestId('tooltip-y-value')).toHaveAttribute( + 'data-result', + formatAmount(1234.56, 'CHF', 2), + ); + expect(screen.getByTestId('tooltip-y-zero')).toHaveAttribute( + 'data-result', + formatAmount(0, 'CHF', 2), + ); + }); + + it('tooltip x: bucket date via toLocaleDateString; missing index → empty string', () => { + render(); + + // Charts format with the partner locale (en-US under test i18n), not the host default. + const expected = new Date(timeline.buckets[0].date).toLocaleDateString('en-US'); + expect(screen.getByTestId('tooltip-x-0')).toHaveAttribute('data-result', expected); + expect(screen.getByTestId('tooltip-x-missing')).toHaveAttribute('data-result', ''); + // opts undefined → dataPointIndex defaults to 0 + expect(screen.getByTestId('tooltip-x-no-opts')).toHaveAttribute('data-result', expected); + }); + + it('empty timeline still builds formatters and shows the empty state (no chart series)', () => { + const empty: PartnerTimeline = { + period: { from: '2026-06-01T00:00:00.000Z', to: '2026-06-03T23:59:59.000Z' }, + currency: 'CHF', + granularity: 'Day', + buckets: [], + meta: {}, + }; + render(); + expect(screen.getByText('No volume data for the selected period.')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-apex-chart')).not.toBeInTheDocument(); + }); +}); + +describe('transactions chart Apex formatters', () => { + it('formats y-axis via formatCount(Math.round(val))', () => { + render(); + + expect(screen.getByTestId('y-fmt-1000')).toHaveAttribute( + 'data-result', + formatCount(1000), + ); + expect(screen.getByTestId('y-fmt-999')).toHaveAttribute('data-result', formatCount(999)); + expect(screen.getByTestId('y-fmt-0')).toHaveAttribute('data-result', formatCount(0)); + }); + + it('tooltip y: NaN → ABSENT_LABEL; finite values use formatCount', () => { + render(); + + expect(screen.getByTestId('tooltip-y-nan')).toHaveAttribute('data-result', ABSENT_LABEL); + expect(screen.getByTestId('tooltip-y-value')).toHaveAttribute( + 'data-result', + formatCount(Math.round(1234.56)), + ); + expect(screen.getByTestId('tooltip-y-zero')).toHaveAttribute( + 'data-result', + formatCount(0), + ); + }); + + it('tooltip x: bucket date via toLocaleDateString; missing index → empty string', () => { + render(); + + const expected = new Date(timeline.buckets[0].date).toLocaleDateString('en-US'); + expect(screen.getByTestId('tooltip-x-0')).toHaveAttribute('data-result', expected); + expect(screen.getByTestId('tooltip-x-missing')).toHaveAttribute('data-result', ''); + }); + + it('empty timeline shows empty state without a chart', () => { + const empty: PartnerTimeline = { + period: { from: '2026-06-01T00:00:00.000Z', to: '2026-06-03T23:59:59.000Z' }, + currency: 'CHF', + granularity: 'Day', + buckets: [], + meta: {}, + }; + render(); + expect(screen.getByText('No transaction data for the selected period.')).toBeInTheDocument(); + expect(screen.queryByTestId('mock-apex-chart')).not.toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/partner-translations.test.ts b/src/__tests__/partner-translations.test.ts new file mode 100644 index 000000000..33f63cad7 --- /dev/null +++ b/src/__tests__/partner-translations.test.ts @@ -0,0 +1,147 @@ +import de from 'src/translations/languages/de.json'; +import fr from 'src/translations/languages/fr.json'; +import itIT from 'src/translations/languages/it.json'; + +/** + * English base keys used by the partner dashboard under screens/partner. + * Removing any of these from a language file must fail this suite (Gegenprobe). + * + * There is no en.json: partnerTranslate() (src/partner-dashboard/util/i18n.ts) + * calls i18next with the English text as both key and defaultValue, so English + * falls back to the literal key text itself. Only de/fr/it are checked here. + */ +const PARTNER_KEYS = [ + 'Non-Custodial Partner Program', + 'NC Partner Program', + 'Total volume', + 'This period', + 'All-time totals', + 'Transactions', + 'Average transaction size', + 'Active users', + 'New users', + 'Registered users (total)', + 'Trading users (total)', + 'Lifetime volume', + 'of registered users', + 'No registered users yet', + 'Volume by cryptocurrency', + 'Fiat currencies', + 'Blockchains', + 'Payment methods', + 'Partner metrics could not be loaded.', + 'Demo data', + '30 days', + '90 days', + '365 days', + 'Day', + 'Week', + 'Month', + 'Period and granularity', + 'Period', + 'Granularity', + 'Volume over time', + 'No volume data for the selected period.', + 'Transactions over time', + 'No transaction data for the selected period.', + 'Shows how much was traded in each period, split into buy, sell and swap.', + 'Shows how many operations happened in each period, split into buy, sell and swap.', + 'Date', + 'No data.', + 'Other', + 'Show as table', + 'Hide table', + 'Referral', + 'Referral volume', + 'Credit earned', + 'Credit paid out', + 'Credit open', + 'Buy', + 'Sell', + 'Swap', + 'An error occurred while loading the dashboard. The page remains usable.', + 'Unknown error', + 'Retry', + 'Try again', + 'Theme', + 'Light', + 'Dark', + 'Language', +] as const; + +type PartnerTranslations = Record; + +function partnerNamespace(doc: unknown): PartnerTranslations { + return (doc as { 'screens/partner': PartnerTranslations })['screens/partner']; +} + +const LANGUAGES: Record = { + de: partnerNamespace(de), + fr: partnerNamespace(fr), + it: partnerNamespace(itIT), +}; + +/** + * Keys whose translated value is deliberately identical to the English source + * text. Each entry below was checked against real precedent elsewhere in the + * same translation files before being allow-listed — this list must not grow + * without that check (that would just soften the test, not verify anything). + * + * - Blockchains (de, fr): kept as the loanword "Blockchain(s)" everywhere in + * the app, not only here — see screens/compliance, screens/home and + * screens/payment in de.json and fr.json, which all keep the identical + * English spelling. + * - Date (fr): French orthography for "date" is spelled identically to + * English; not a missed translation. + * - Referral (de, it): kept as an English loanword in both languages, + * consistent with the compound "Referral volume" translations in this same + * screens/partner section ("Referral-Volumen", "Volume referral") — the verb + * stem stays "Referral", only the surrounding word is translated. + * - Swap (de): kept as the loanword in German across the app (screens/safe, + * navigation/links), and already used untranslated inside two other + * screens/partner sentences in this very file + * ("aufgeteilt nach Kauf, Verkauf und Swap"). + * + * Blockchains (de) and Swap (de) were not named in the PR review notes that + * flagged the other three — flagged here for a human to confirm, not silently + * assumed. + */ +const ALLOWED_UNTRANSLATED: Record = { + de: ['Blockchains', 'Referral', 'Swap'], + fr: ['Blockchains', 'Date'], + it: ['Referral'], +}; + +describe.each(Object.entries(LANGUAGES))('partner dashboard translations — %s', (lang, table) => { + it('defines a non-empty translation for every partner UI key', () => { + expect(table).toBeDefined(); + for (const key of PARTNER_KEYS) { + const value = table[key]; + expect(value).toBeDefined(); + expect(typeof value).toBe('string'); + expect(value.length).toBeGreaterThan(0); + } + }); + + it('has exactly the canonical screens/partner key set (no missing, no extra keys)', () => { + expect(Object.keys(table).sort()).toEqual([...PARTNER_KEYS].sort()); + }); + + it('has no key left as the literal English source text outside the reviewed exceptions', () => { + const allowed = new Set(ALLOWED_UNTRANSLATED[lang] ?? []); + const untranslated = PARTNER_KEYS.filter((key) => table[key] === key && !allowed.has(key)); + expect(untranslated).toEqual([]); + }); +}); + +describe('partner dashboard translations (screens/partner)', () => { + it('keeps a few known German strings that partners already saw', () => { + const partner = LANGUAGES.de; + expect(partner['Non-Custodial Partner Program']).toBe('Non-Custodial Partnerprogramm'); + expect(partner['NC Partner Program']).toBe('NC Partner Programm'); + expect(partner['Total volume']).toBe('Gesamtvolumen'); + expect(partner['New users']).toBe('Neue Nutzer'); + expect(partner['Show as table']).toBe('Als Tabelle anzeigen'); + expect(partner['Buy']).toBe('Kauf'); + }); +}); diff --git a/src/components/navigation.tsx b/src/components/navigation.tsx index 4aa77fc41..b0122c165 100644 --- a/src/components/navigation.tsx +++ b/src/components/navigation.tsx @@ -1,5 +1,5 @@ import { useAuthContext, UserRole, useSessionContext, useUserContext } from '@dfx.swiss/react'; -import { SUPPORT_DASHBOARD_ROLES } from 'src/hooks/guard.hook'; +import { isPartnerDashboardRole, SUPPORT_DASHBOARD_ROLES } from 'src/hooks/guard.hook'; import { DfxIcon, IconColor, @@ -115,7 +115,7 @@ function MenuIcon({ icon, setIsNavigationOpen }: IconContentProps): JSX.Element ); } -function NavigationMenu({ setIsNavigationOpen, small = false }: NavigationMenuContentProps): JSX.Element { +function NavigationMenu({ setIsNavigationOpen, small }: NavigationMenuContentProps): JSX.Element { const { navigate } = useNavigation(); const { translate } = useSettingsContext(); const { hasCustody } = useUserContext(); @@ -216,6 +216,15 @@ function NavigationMenu({ setIsNavigationOpen, small = false }: NavigationMenuCo onClose={() => setIsNavigationOpen(false)} /> )} + {session?.role && isPartnerDashboardRole(session.role) && ( + setIsNavigationOpen(false)} + /> + )} {session?.role && [UserRole.ADMIN, UserRole.REALUNIT, UserRole.COMPLIANCE].includes(session.role) && ( { + if (isActive && isInitialized && (!isLoggedIn || (session && !isPartnerDashboardRole(session.role)))) { + navigate(redirectPath, { setRedirect: true }); + } + }, [session, isLoggedIn, isInitialized, navigate, isActive, redirectPath]); +} + +// Defaults removed: every caller passes redirectPath and isActive explicitly. +// Keeping defaults left two unreachable branches and blocked 100 % branch coverage. +function useUserRoleGuard(requiresUserRoles: UserRole[], redirectPath: string, isActive: boolean) { const { isLoggedIn } = useSessionContext(); const { isInitialized } = useWalletContext(); const { navigate } = useNavigation(); @@ -39,7 +75,9 @@ function useUserRoleGuard(requiresUserRoles: UserRole[], redirectPath = '/', isA if (isActive && isInitialized && (!isLoggedIn || (session && !requiresUserRoles.includes(session.role)))) { navigate(redirectPath, { setRedirect: true }); } - }, [session, isLoggedIn, isInitialized, navigate, isActive]); + // requiresUserRoles is a stable allow-list from the caller (module const or inline + // literal); listing it would re-fire every render for `[UserRole.ADMIN]` callers. + }, [session, isLoggedIn, isInitialized, navigate, isActive, redirectPath]); } function useSessionGuard(requireActiveAddress: boolean, redirectPath: string, isActive: boolean) { diff --git a/src/hooks/partner-dashboard.hook.ts b/src/hooks/partner-dashboard.hook.ts new file mode 100644 index 000000000..df582da00 --- /dev/null +++ b/src/hooks/partner-dashboard.hook.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo } from 'react'; +import { + PartnerGranularity, + PartnerStatistic, + PartnerTimeline, +} from 'src/dto/partner-statistic.dto'; +import { + buildPartnerStatisticFixture, + buildPartnerTimelineFixture, +} from 'src/partner-dashboard/fixtures/partner-statistic.fixture'; +import { isFixtureMode } from 'src/partner-dashboard/util/format'; +import { useGuardedApi } from './guarded-api.hook'; + +export interface PartnerQuery { + from?: string; + to?: string; + granularity?: PartnerGranularity; +} + +const DEFAULT_GRANULARITY: PartnerGranularity = 'Day'; + +function buildQuery(params: PartnerQuery): string { + const search = new URLSearchParams(); + if (params.from) search.set('from', params.from); + if (params.to) search.set('to', params.to); + if (params.granularity) search.set('granularity', params.granularity); + const q = search.toString(); + return q ? `?${q}` : ''; +} + +/** + * Partner metrics via the main-app session (`useGuardedApi` + session token). + * Fixture mode returns baked-in data and never hits the network — kept so + * presentation tests can render the dashboard without API/auth providers. + */ +export function usePartnerDashboard() { + // staff/partner endpoints answer HTTP 403 { code: 'TFA_REQUIRED' } when the session still needs 2FA; + // useGuardedApi routes that into the bearer-based 2FA flow instead of surfacing a raw error + const { call: guardedCall } = useGuardedApi(); + const fixture = isFixtureMode(); + + const getPartnerStatistic = useCallback( + async (params: PartnerQuery = {}): Promise => { + if (fixture) { + return buildPartnerStatisticFixture({ from: params.from, to: params.to }); + } + return guardedCall({ + url: `statistic/partner${buildQuery(params)}`, + method: 'GET', + }); + }, + [fixture, guardedCall], + ); + + const getPartnerTimeline = useCallback( + async (params: PartnerQuery = {}): Promise => { + if (fixture) { + return buildPartnerTimelineFixture(params.granularity ?? DEFAULT_GRANULARITY, { + from: params.from, + to: params.to, + }); + } + return guardedCall({ + url: `statistic/partner/timeline${buildQuery(params)}`, + method: 'GET', + }); + }, + [fixture, guardedCall], + ); + + return useMemo( + () => ({ getPartnerStatistic, getPartnerTimeline, isFixture: fixture }), + [getPartnerStatistic, getPartnerTimeline, fixture], + ); +} diff --git a/src/partner-dashboard/App.tsx b/src/partner-dashboard/App.tsx new file mode 100644 index 000000000..1beaca220 --- /dev/null +++ b/src/partner-dashboard/App.tsx @@ -0,0 +1,281 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { PartnerGranularity, PartnerStatistic, PartnerTimeline } from 'src/dto/partner-statistic.dto'; +import { usePartnerDashboard } from 'src/hooks/partner-dashboard.hook'; +import { ErrorState } from './components/error-state'; +import { PartnerHeader } from './components/header'; +import { HorizontalBarList } from './components/horizontal-bar-list'; +import { KpiTile } from './components/kpi-tile'; +import { PeriodControls, PeriodDays } from './components/period-controls'; +import { ReferralBlock } from './components/referral-block'; +import { DashboardSkeleton } from './components/skeleton'; +import { TransactionsTimeChart } from './components/transactions-time-chart'; +import { VolumeTimeChart } from './components/volume-time-chart'; +import './styles/partner.css'; +import { + computeConversionRate, + formatAmount, + formatAmountWhole, + formatCount, + formatPercent, +} from './util/format'; +import { usePartnerTranslation } from './util/i18n'; +import { themeClassName, usePartnerTheme } from './util/theme'; + +/** Exported for unit tests that pin the inclusive day window (30 → days-1). */ +export function periodRange(days: PeriodDays): { from: string; to: string } { + const to = new Date(); + const from = new Date(to); + from.setUTCDate(from.getUTCDate() - (days - 1)); + from.setUTCHours(0, 0, 0, 0); + return { from: from.toISOString(), to: to.toISOString() }; +} + +/** Load failure: raw API message, or a marker localized only at render time. */ +type LoadError = { kind: 'raw'; message: string } | { kind: 'fallback' }; + +/** + * Partner dashboard presentation (charts, KPIs, referral, breakdowns). + * Auth/role gate lives on the main-app screen; this view assumes a valid session. + * Theme is dashboard-local (class on this root only) and survives remount via localStorage. + */ +export default function PartnerDashboardView(): JSX.Element { + const { getPartnerStatistic, getPartnerTimeline, isFixture } = usePartnerDashboard(); + const { translate, locale } = usePartnerTranslation(); + const { theme, setTheme } = usePartnerTheme(); + + const [periodDays, setPeriodDays] = useState(30); + const [granularity, setGranularity] = useState('Day'); + const [statistic, setStatistic] = useState(null); + const [timeline, setTimeline] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [reloadToken, setReloadToken] = useState(0); + // Period/granularity can re-trigger load while one is in flight — only the newest write wins + // (same pattern as support-dashboard-overview.screen.tsx statsRequestId). + const loadRequestId = useRef(0); + + const range = useMemo(() => periodRange(periodDays), [periodDays]); + + const load = useCallback(async () => { + const requestId = ++loadRequestId.current; + const isCurrent = (): boolean => requestId === loadRequestId.current; + + if (isCurrent()) { + setLoading(true); + setError(null); + } + try { + const query = { from: range.from, to: range.to, granularity }; + const [stat, tl] = await Promise.all([getPartnerStatistic(query), getPartnerTimeline(query)]); + if (!isCurrent()) return; + setStatistic(stat); + setTimeline(tl); + } catch (err) { + if (!isCurrent()) return; + // Store a stable marker / raw message — never call translate() here. Language switches + // rebuild `t` and would otherwise re-run load (see support-dashboard-overview). + if (err instanceof Error && err.message) { + setError({ kind: 'raw', message: err.message }); + } else { + setError({ kind: 'fallback' }); + } + setStatistic(null); + setTimeline(null); + } finally { + if (isCurrent()) setLoading(false); + } + }, [getPartnerStatistic, getPartnerTimeline, range.from, range.to, granularity]); + + useEffect(() => { + void load(); + }, [load, reloadToken]); + + const errorMessage = + error == null + ? null + : error.kind === 'fallback' + ? translate('Partner metrics could not be loaded.') + : error.message; + + const currency = statistic?.currency ?? 'CHF'; + const conversionRate = + statistic != null + ? computeConversionRate(statistic.allTime.tradingUsers, statistic.allTime.registeredUsers) + : null; + + const assetRows = useMemo(() => { + if (!statistic) return []; + const map = new Map(); + for (const a of statistic.breakdown.assets) { + const key = a.blockchain ? `${a.name} (${a.blockchain})` : a.name; + const prev = map.get(key); + if (!prev) { + map.set(key, { volume: a.volume, transactions: a.transactions }); + continue; + } + map.set(key, { + volume: prev.volume + a.volume, + transactions: prev.transactions + a.transactions, + }); + } + return Array.from(map.entries()).map(([name, v]) => ({ name, ...v })); + }, [statistic]); + + return ( +
+
+ + + {loading && } + + {!loading && errorMessage && ( + setReloadToken((t) => t + 1)} /> + )} + + {!loading && error == null && statistic && ( + <> +
+ {/* + Label sits with the period/granularity controls as one toolbar group + (left of the buttons, tight gap) so it is not a floating section title. + */} +
+

+ {translate('This period')} +

+ +
+
+ formatAmountWhole(n, currency, locale)} + testId="kpi-volume" + /> + formatCount(n, locale)} + testId="kpi-transactions" + /> + formatAmount(n, currency, 2, locale)} + testId="kpi-avg" + /> + formatCount(n, locale)} + testId="kpi-active-users" + /> + formatCount(n, locale)} + testId="kpi-new-users" + /> +
+
+ +
+

+ {translate('All-time totals')} +

+
+ formatCount(n, locale)} + testId="kpi-registered" + /> + formatCount(n, locale)} + caption={ + conversionRate == null + ? translate('No registered users yet') + : `${formatPercent(conversionRate, 1, locale)} ${translate('of registered users')}` + } + testId="kpi-trading-users" + /> + formatAmountWhole(n, currency, locale)} + caption={`${translate('Buy')}: ${formatAmountWhole(statistic.allTime.volume.buy, currency, locale)} · ${translate( + 'Sell', + )}: ${formatAmountWhole(statistic.allTime.volume.sell, currency, locale)}`} + testId="kpi-lifetime-volume" + /> +
+
+ + + + {timeline && ( +
+ + +
+ )} + +
+ + + + +
+ + )} +
+
+ ); +} diff --git a/src/partner-dashboard/components/collapsible-table.tsx b/src/partner-dashboard/components/collapsible-table.tsx new file mode 100644 index 000000000..abb847c65 --- /dev/null +++ b/src/partner-dashboard/components/collapsible-table.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; + +export interface TableColumn { + key: string; + header: string; + align?: 'left' | 'right'; +} + +export interface CollapsibleTableProps { + title: string; + columns: TableColumn[]; + rows: Array>; + defaultOpen?: boolean; +} + +/** Body rows visible without scrolling; beyond this the body scrolls under a sticky header. */ +export const TABLE_SCROLL_ROW_LIMIT = 20; + +/** + * Max height for header + N body rows, derived from line-height + vertical cell padding + * (py-1.5 → 0.75rem). Uses 1lh so the cap grows when font-size / line-height changes — + * not a hard-coded pixel row height. + */ +export function tableScrollMaxHeight(rowLimit: number = TABLE_SCROLL_ROW_LIMIT): string { + return `calc((1lh + 0.75rem) * ${rowLimit + 1})`; +} + +/** Accessible numbers-as-table toggle for chart series. */ +export function CollapsibleTable({ title, columns, rows, defaultOpen = false }: CollapsibleTableProps): JSX.Element { + const [open, setOpen] = useState(defaultOpen); + const { translate } = usePartnerTranslation(); + + const needsScroll = rows.length > TABLE_SCROLL_ROW_LIMIT; + + return ( +
+ + {open && ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, idx) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.header} +
+ {row[col.key] ?? '–'} +
+
+ )} +
+ ); +} diff --git a/src/partner-dashboard/components/empty-state.tsx b/src/partner-dashboard/components/empty-state.tsx new file mode 100644 index 000000000..d2c7ed510 --- /dev/null +++ b/src/partner-dashboard/components/empty-state.tsx @@ -0,0 +1,10 @@ +export function EmptyState({ message }: { message: string }): JSX.Element { + return ( +
+ {message} +
+ ); +} diff --git a/src/partner-dashboard/components/error-boundary.tsx b/src/partner-dashboard/components/error-boundary.tsx new file mode 100644 index 000000000..47fddbff8 --- /dev/null +++ b/src/partner-dashboard/components/error-boundary.tsx @@ -0,0 +1,64 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; +import { partnerTranslate } from 'src/partner-dashboard/util/i18n'; + +interface PartnerErrorBoundaryProps { + children: ReactNode; +} + +interface PartnerErrorBoundaryState { + error: Error | null; +} + +/** + * Keeps the partner shell usable when a child throws (e.g. context bootstrap failure). + * Does not rethrow — shows an embedded message instead of the full-page React overlay path. + */ +export class PartnerErrorBoundary extends Component { + state: PartnerErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): PartnerErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo): void { + // Keep a breadcrumb for debugging; never rethrow. + // eslint-disable-next-line no-console + console.error('Partner dashboard error boundary:', error, info.componentStack); + } + + private handleRetry = (): void => { + this.setState({ error: null }); + }; + + render(): ReactNode { + const { error } = this.state; + if (error) { + return ( +
+
+

+ {partnerTranslate( + 'An error occurred while loading the dashboard. The page remains usable.', + )} +

+

+ {error.message || partnerTranslate('Unknown error')} +

+ +
+
+ ); + } + return this.props.children; + } +} diff --git a/src/partner-dashboard/components/error-state.tsx b/src/partner-dashboard/components/error-state.tsx new file mode 100644 index 000000000..763250cf0 --- /dev/null +++ b/src/partner-dashboard/components/error-state.tsx @@ -0,0 +1,27 @@ +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; + +export interface ErrorStateProps { + message: string; + onRetry: () => void; +} + +export function ErrorState({ message, onRetry }: ErrorStateProps): JSX.Element { + const { translate } = usePartnerTranslation(); + + return ( +
+

{message}

+ +
+ ); +} diff --git a/src/partner-dashboard/components/header.tsx b/src/partner-dashboard/components/header.tsx new file mode 100644 index 000000000..ae46df670 --- /dev/null +++ b/src/partner-dashboard/components/header.tsx @@ -0,0 +1,119 @@ +import { Language } from '@dfx.swiss/react'; +import { useEffect } from 'react'; +import { useSettingsContext } from 'src/contexts/settings.context'; +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; +import { PartnerTheme } from 'src/partner-dashboard/util/theme'; + +export interface PartnerHeaderProps { + isFixture?: boolean; + theme: PartnerTheme; + onThemeChange: (theme: PartnerTheme) => void; +} + +/** + * Header: single bold page title on the left, secondary controls (theme + language) + * grouped on the right. Language mutates the main-app settings context so the rest + * of the app stays in sync. Theme is dashboard-local only. + */ +export function PartnerHeader({ isFixture, theme, onThemeChange }: PartnerHeaderProps): JSX.Element { + const { translate } = usePartnerTranslation(); + const { language, availableLanguages, changeLanguage } = useSettingsContext(); + const title = translate('Non-Custodial Partner Program'); + + useEffect(() => { + const previous = document.title; + document.title = title; + return () => { + document.title = previous; + }; + }, [title]); + + /** + * Offer every language the app already supports (DE/EN/FR/IT via `availableLanguages`). + * Restricting to DE/EN would leave FR/IT users without an active segment and force a + * settings-page detour for languages the rest of the product already ships. Symbols are + * compact enough for the segmented control; the Auftraggeber named DE/EN as examples, + * not as a hard filter. + */ + const languages = availableLanguages; + + function selectLanguage(lang: Language): void { + changeLanguage(lang); + } + + return ( +
+
+
+
+

+ {title} +

+ {isFixture && ( + + {translate('Demo data')} + + )} +
+
+ +
+
+ + +
+ + {languages.length > 0 && ( +
+ {languages.map((lang) => { + const active = language?.id === lang.id || language?.symbol === lang.symbol; + return ( + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/partner-dashboard/components/horizontal-bar-list.tsx b/src/partner-dashboard/components/horizontal-bar-list.tsx new file mode 100644 index 000000000..055c56e8e --- /dev/null +++ b/src/partner-dashboard/components/horizontal-bar-list.tsx @@ -0,0 +1,77 @@ +import { formatAmount, formatCount } from 'src/partner-dashboard/util/format'; +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; +import { hasActivity, NamedVolumeRow, rankNamedVolumes, sequentialColor } from 'src/partner-dashboard/util/series'; +import { PartnerTheme } from 'src/partner-dashboard/util/theme'; +import { EmptyState } from './empty-state'; + +export interface HorizontalBarListProps { + title: string; + rows: NamedVolumeRow[]; + currency: string; + theme?: PartnerTheme; + compact?: boolean; + testId?: string; +} + +/** + * Horizontal bars, descending, sequential single-hue scale, direct value labels. + * A category with no activity in the period (zero volume AND zero transactions) is + * dropped entirely — it never sits there permanently showing 0. + */ +export function HorizontalBarList({ + title, + rows, + currency, + theme = 'dark', + compact = false, + testId, +}: HorizontalBarListProps): JSX.Element { + const { translate, locale } = usePartnerTranslation(); + const ranked = rankNamedVolumes(rows.filter(hasActivity), 12, translate('Other')); + const maxVolume = ranked.reduce((m, r) => Math.max(m, r.volume), 0); + + return ( +
+

+ {title} +

+ {ranked.length === 0 ? ( + + ) : ( +
+
    + {ranked.map((row, index) => { + const pct = maxVolume > 0 ? (row.volume / maxVolume) * 100 : 0; + const color = sequentialColor(index, ranked.length, theme); + return ( +
  • +
    + + {row.name} + + + {formatAmount(row.volume, currency, 0, locale)} + + ({formatCount(row.transactions, locale)}) + + +
    +
    +
    0 ? 1 : 0)}%`, backgroundColor: color }} + title={formatAmount(row.volume, currency, 2, locale)} + /> +
    +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/partner-dashboard/components/kpi-tile.tsx b/src/partner-dashboard/components/kpi-tile.tsx new file mode 100644 index 000000000..a4db85a1b --- /dev/null +++ b/src/partner-dashboard/components/kpi-tile.tsx @@ -0,0 +1,52 @@ +import { displayNullable } from 'src/partner-dashboard/util/format'; + +export interface KpiTileProps { + label: string; + value: number | null | undefined; + format: (n: number) => string; + /** Optional secondary line under the value (e.g. conversion rate, buy/sell split). */ + caption?: string; + testId?: string; +} + +/** + * Pure numeric KPI tile. null → genuinely absent (never drawn as 0); 0 → real zero. + * Value is never truncated: tile grows in height; type scales down on narrow widths. + */ +export function KpiTile({ label, value, format, caption, testId }: KpiTileProps): JSX.Element { + const display = displayNullable(value, format); + + return ( +
+
{label}
+ {display.kind === 'absent' ? ( +
+ {display.text} +
+ ) : ( +
+ {display.text} +
+ )} + {caption != null && ( +
+ {caption} +
+ )} +
+ ); +} diff --git a/src/partner-dashboard/components/period-controls.tsx b/src/partner-dashboard/components/period-controls.tsx new file mode 100644 index 000000000..36e4c3440 --- /dev/null +++ b/src/partner-dashboard/components/period-controls.tsx @@ -0,0 +1,95 @@ +import { useMemo } from 'react'; +import { PARTNER_GRANULARITIES, PartnerGranularity } from 'src/dto/partner-statistic.dto'; +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; + +export type PeriodDays = 30 | 90 | 365; + +export interface PeriodControlsProps { + periodDays: PeriodDays; + granularity: PartnerGranularity; + /** @deprecated Ignored — active filters use design-pod --primary, not partner magenta. */ + accent?: string; + onPeriodChange: (days: PeriodDays) => void; + onGranularityChange: (g: PartnerGranularity) => void; +} + +const PERIOD_DAYS: PeriodDays[] = [30, 90, 365]; + +const GRANULARITY_KEYS: Record = { + Day: 'Day', + Week: 'Week', + Month: 'Month', +}; + +export function PeriodControls({ + periodDays, + granularity, + onPeriodChange, + onGranularityChange, +}: PeriodControlsProps): JSX.Element { + const { translate } = usePartnerTranslation(); + + const periodOptions = useMemo( + () => + PERIOD_DAYS.map((days) => ({ + days, + label: translate(`${days} days` as '30 days'), + })), + [translate], + ); + + const granularityOptions = useMemo( + () => + PARTNER_GRANULARITIES.map((value) => ({ + value, + label: translate(GRANULARITY_KEYS[value]), + })), + [translate], + ); + + return ( +
+
+ {periodOptions.map((opt) => { + const active = opt.days === periodDays; + return ( + + ); + })} +
+
+ {granularityOptions.map((opt) => { + const active = opt.value === granularity; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/partner-dashboard/components/referral-block.tsx b/src/partner-dashboard/components/referral-block.tsx new file mode 100644 index 000000000..a35898b3c --- /dev/null +++ b/src/partner-dashboard/components/referral-block.tsx @@ -0,0 +1,149 @@ +import { PartnerReferral } from 'src/dto/partner-statistic.dto'; +import { formatAmount } from 'src/partner-dashboard/util/format'; +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; + +export interface ReferralBlockProps { + referral: PartnerReferral; +} + +/** + * Referral block — values are EUR while the rest of the dashboard is CHF (unmissable badge, + * repeated next to the hero figure since misreading the currency here is a real error). + * + * `creditOpen` (money still owed to the partner) is the hero — the number that matters. + * `creditEarned` and `creditPaid` are shown as one split bar (paid share vs. open share) + * instead of three equal-weight numbers, so the relationship reads at a glance. + * `volume` is context, not a credit figure — visually separated below a divider. + * + * Colours are grayscale-neutral (`var(--text)` / `var(--surface-2)`) on purpose: paid vs. + * open is not a good/bad state, so no status (green/red) or brand-accent colour is used. + */ +export function ReferralBlock({ referral }: ReferralBlockProps): JSX.Element { + const { translate, locale } = usePartnerTranslation(); + const currency = referral.currency; + + // Share of `creditEarned` already paid out, as a fraction of the bar (not of `creditOpen` + // directly — `creditOpen` also folds in the account's separate `refCredit`, so it does not + // always equal `creditEarned - creditPaid` exactly). Segments always sum to the full bar. + const earned = referral.creditEarned; + const paidShare = earned > 0 ? Math.min(Math.max(referral.creditPaid / earned, 0), 1) : 0; + const openShare = earned > 0 ? Math.max(1 - paidShare, 0) : 0; + const paidPct = paidShare * 100; + const openPct = openShare * 100; + + // Hero number is currency-free: the adjacent {currency} owns the unit + // (formatAmount would otherwise append it and produce "285.40 EUR EUR"). + // paid/earned/volume keep formatAmount — they have no second currency marker. + const openText = referral.creditOpen.toLocaleString(locale, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + const paidText = formatAmount(referral.creditPaid, currency, 2, locale); + const earnedText = formatAmount(referral.creditEarned, currency, 2, locale); + const volumeText = formatAmount(referral.volume, currency, 2, locale); + const openWithCurrency = `${openText} ${currency}`; + + return ( +
+
+

+ {translate('Referral')} +

+ + {currency} + +
+ + {/* Hero: credit open — the figure that matters to a partner */} +
+
{translate('Credit open')}
+
+ + {openText} + + {/* Currency repeated right at the hero number — the one figure most likely to be misread as CHF. */} + {currency} +
+
+ + {/* earned = paid + open, shown as one picture instead of three equal numbers */} +
+
+ {translate('Credit earned')} + + {earnedText} + +
+
+ {paidPct > 0 && ( +
+ )} + {openPct > 0 && ( +
+ )} +
+
+ + + + +
+
+ + {/* Context, not a credit figure — separated below a divider, not a fourth equal number */} +
+ {translate('Referral volume')} + + {volumeText} + +
+
+ ); +} diff --git a/src/partner-dashboard/components/skeleton.tsx b/src/partner-dashboard/components/skeleton.tsx new file mode 100644 index 000000000..1301a68ee --- /dev/null +++ b/src/partner-dashboard/components/skeleton.tsx @@ -0,0 +1,17 @@ +export function DashboardSkeleton(): JSX.Element { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+
+
+
+
+
+ ); +} diff --git a/src/partner-dashboard/components/transactions-time-chart.tsx b/src/partner-dashboard/components/transactions-time-chart.tsx new file mode 100644 index 000000000..cf75b95d0 --- /dev/null +++ b/src/partner-dashboard/components/transactions-time-chart.tsx @@ -0,0 +1,151 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { SERIES_LABELS } from 'src/config/partner-dashboard.config'; +import { PartnerTimeline } from 'src/dto/partner-statistic.dto'; +import { baseChartOptions } from 'src/partner-dashboard/util/chart-theme'; +import { ABSENT_LABEL, formatCount } from 'src/partner-dashboard/util/format'; +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; +import { timelineSeries } from 'src/partner-dashboard/util/series'; +import { PartnerTheme } from 'src/partner-dashboard/util/theme'; +import { buildTimelineXAxis, timelineSeriesValues } from 'src/partner-dashboard/util/timeline-axis'; +import { CollapsibleTable } from './collapsible-table'; +import { EmptyState } from './empty-state'; + +export interface TransactionsTimeChartProps { + timeline: PartnerTimeline; + theme: PartnerTheme; +} + +/** Separate chart for transaction counts — never a second Y-axis on the volume chart. */ +export function TransactionsTimeChart({ timeline, theme }: TransactionsTimeChartProps): JSX.Element { + const { buckets, granularity } = timeline; + // Every bucket always carries a real transactions group — "no data" only means an empty period. + const hasData = buckets.length > 0; + const { translate, locale } = usePartnerTranslation(); + + const seriesLabels = useMemo( + () => ({ + buy: translate(SERIES_LABELS.buy), + sell: translate(SERIES_LABELS.sell), + swap: translate(SERIES_LABELS.swap), + }), + [translate], + ); + + const series = useMemo( + () => [ + { + name: seriesLabels.buy, + data: timelineSeriesValues(timelineSeries(buckets, 'transactions', 'buy')), + }, + { + name: seriesLabels.sell, + data: timelineSeriesValues(timelineSeries(buckets, 'transactions', 'sell')), + }, + { + name: seriesLabels.swap, + data: timelineSeriesValues(timelineSeries(buckets, 'transactions', 'swap')), + }, + ], + [buckets, seriesLabels], + ); + + const options = useMemo((): ApexOptions => { + const base = baseChartOptions(theme); + const axisColor = (base.xaxis?.labels?.style?.colors as string) ?? ''; + const timelineAxis = buildTimelineXAxis({ + buckets, + granularity, + locale, + axisColor, + }); + return { + ...base, + xaxis: { + ...base.xaxis, + ...timelineAxis.xaxis, + labels: { + ...base.xaxis?.labels, + ...timelineAxis.xaxis?.labels, + style: { + ...base.xaxis?.labels?.style, + ...timelineAxis.xaxis?.labels?.style, + }, + }, + axisBorder: { ...base.xaxis?.axisBorder, ...timelineAxis.xaxis?.axisBorder }, + axisTicks: { ...base.xaxis?.axisTicks, ...timelineAxis.xaxis?.axisTicks }, + }, + grid: { + ...base.grid, + ...timelineAxis.grid, + xaxis: { ...timelineAxis.grid?.xaxis }, + }, + yaxis: { + labels: { + style: { colors: axisColor }, + formatter: (val: number) => formatCount(Math.round(val), locale), + }, + forceNiceScale: true, + }, + tooltip: { + ...base.tooltip, + x: { + formatter: (_val, opts) => { + const idx = opts?.dataPointIndex ?? 0; + const iso = buckets[idx]?.date; + return iso ? new Date(iso).toLocaleDateString(locale) : ''; + }, + }, + y: { + formatter: (val: number) => { + if (Number.isNaN(val)) { + return ABSENT_LABEL; + } + return formatCount(Math.round(val), locale); + }, + }, + }, + }; + }, [buckets, granularity, locale, theme]); + + const tableRows = buckets.map((b) => ({ + date: new Date(b.date).toLocaleDateString(locale), + buy: formatCount(b.transactions.buy, locale), + sell: formatCount(b.transactions.sell, locale), + swap: formatCount(b.transactions.swap, locale), + })); + + const title = translate('Transactions over time'); + const description = translate( + 'Shows how many operations happened in each period, split into buy, sell and swap.', + ); + + return ( +
+

+ {title} +

+

+ {description} +

+ {!hasData ? ( + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/src/partner-dashboard/components/volume-time-chart.tsx b/src/partner-dashboard/components/volume-time-chart.tsx new file mode 100644 index 000000000..27b40224f --- /dev/null +++ b/src/partner-dashboard/components/volume-time-chart.tsx @@ -0,0 +1,141 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { SERIES_LABELS } from 'src/config/partner-dashboard.config'; +import { PartnerTimeline } from 'src/dto/partner-statistic.dto'; +import { baseChartOptions } from 'src/partner-dashboard/util/chart-theme'; +import { ABSENT_LABEL, formatAmount } from 'src/partner-dashboard/util/format'; +import { usePartnerTranslation } from 'src/partner-dashboard/util/i18n'; +import { timelineSeries } from 'src/partner-dashboard/util/series'; +import { PartnerTheme } from 'src/partner-dashboard/util/theme'; +import { buildTimelineXAxis, timelineSeriesValues } from 'src/partner-dashboard/util/timeline-axis'; +import { CollapsibleTable } from './collapsible-table'; +import { EmptyState } from './empty-state'; + +export interface VolumeTimeChartProps { + timeline: PartnerTimeline; + theme: PartnerTheme; +} + +export function VolumeTimeChart({ timeline, theme }: VolumeTimeChartProps): JSX.Element { + const { buckets, currency, granularity } = timeline; + // Every bucket always carries a real volume group — "no data" only means an empty period. + const hasData = buckets.length > 0; + const { translate, locale } = usePartnerTranslation(); + + const seriesLabels = useMemo( + () => ({ + buy: translate(SERIES_LABELS.buy), + sell: translate(SERIES_LABELS.sell), + swap: translate(SERIES_LABELS.swap), + }), + [translate], + ); + + const series = useMemo( + () => [ + { name: seriesLabels.buy, data: timelineSeriesValues(timelineSeries(buckets, 'volume', 'buy')) }, + { name: seriesLabels.sell, data: timelineSeriesValues(timelineSeries(buckets, 'volume', 'sell')) }, + { name: seriesLabels.swap, data: timelineSeriesValues(timelineSeries(buckets, 'volume', 'swap')) }, + ], + [buckets, seriesLabels], + ); + + const options = useMemo((): ApexOptions => { + const base = baseChartOptions(theme); + const axisColor = (base.xaxis?.labels?.style?.colors as string) ?? ''; + const timelineAxis = buildTimelineXAxis({ + buckets, + granularity, + locale, + axisColor, + }); + return { + ...base, + xaxis: { + ...base.xaxis, + ...timelineAxis.xaxis, + labels: { + ...base.xaxis?.labels, + ...timelineAxis.xaxis?.labels, + style: { + ...base.xaxis?.labels?.style, + ...timelineAxis.xaxis?.labels?.style, + }, + }, + axisBorder: { ...base.xaxis?.axisBorder, ...timelineAxis.xaxis?.axisBorder }, + axisTicks: { ...base.xaxis?.axisTicks, ...timelineAxis.xaxis?.axisTicks }, + }, + grid: { + ...base.grid, + ...timelineAxis.grid, + xaxis: { ...timelineAxis.grid?.xaxis }, + }, + yaxis: { + labels: { + style: { colors: axisColor }, + formatter: (val: number) => + val >= 1000 ? `${(val / 1000).toFixed(0)}k` : val.toFixed(0), + }, + }, + tooltip: { + ...base.tooltip, + x: { + formatter: (_val, opts) => { + const idx = opts?.dataPointIndex ?? 0; + const iso = buckets[idx]?.date; + return iso ? new Date(iso).toLocaleDateString(locale) : ''; + }, + }, + y: { + formatter: (val: number) => { + if (Number.isNaN(val)) { + return ABSENT_LABEL; + } + return formatAmount(val, currency, 2, locale); + }, + }, + }, + }; + }, [buckets, currency, granularity, locale, theme]); + + const tableRows = buckets.map((b) => ({ + date: new Date(b.date).toLocaleDateString(locale), + buy: formatAmount(b.volume.buy, currency, 2, locale), + sell: formatAmount(b.volume.sell, currency, 2, locale), + swap: formatAmount(b.volume.swap, currency, 2, locale), + })); + + const title = translate('Volume over time'); + const description = translate( + 'Shows how much was traded in each period, split into buy, sell and swap.', + ); + + return ( +
+

+ {title} +

+

+ {description} +

+ {!hasData ? ( + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/src/partner-dashboard/fixtures/partner-statistic.fixture.ts b/src/partner-dashboard/fixtures/partner-statistic.fixture.ts new file mode 100644 index 000000000..46332bd75 --- /dev/null +++ b/src/partner-dashboard/fixtures/partner-statistic.fixture.ts @@ -0,0 +1,373 @@ +import { + PartnerGranularity, + PartnerStatistic, + PartnerTimeline, + PartnerTimelineBucket, +} from 'src/dto/partner-statistic.dto'; + +/** Default demo window (30 calendar days ending 2026-06-30) — used when callers omit range. */ +const DEFAULT_PERIOD_TO = '2026-06-30T23:59:59.000Z'; +const DEFAULT_SPAN_DAYS = 30; + +export interface FixtureRange { + from?: string; + to?: string; +} + +function startOfUtcDay(d: Date): Date { + const out = new Date(d); + out.setUTCHours(0, 0, 0, 0); + return out; +} + +function isoAtUtcMidnight(d: Date): string { + return startOfUtcDay(d).toISOString(); +} + +/** Inclusive day count between two ISO timestamps (UTC calendar days). */ +function spanDays(fromIso: string, toIso: string): number { + const from = startOfUtcDay(new Date(fromIso)).getTime(); + const to = startOfUtcDay(new Date(toIso)).getTime(); + const dayMs = 24 * 60 * 60 * 1000; + return Math.max(1, Math.round((to - from) / dayMs) + 1); +} + +function resolveRange(range?: FixtureRange): { from: string; to: string; days: number } { + const to = range?.to ?? DEFAULT_PERIOD_TO; + let from = range?.from; + if (!from) { + const end = new Date(to); + end.setUTCDate(end.getUTCDate() - (DEFAULT_SPAN_DAYS - 1)); + from = isoAtUtcMidnight(end); + } + return { from, to, days: spanDays(from, to) }; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +function scaleNum(n: number, factor: number): number { + return round2(n * factor); +} + +/** + * Realistic Cake partner fixture. + * allTime volumes/users are production-checked Cake values (not scaled). + * Period totals/breakdown scale with the requested window relative to the 30-day base. + * Values are real throughout — averageTransactionVolume is the only field that can be + * null (no transactions in the period), per the API contract. + */ +export function buildPartnerStatisticFixture(range?: FixtureRange): PartnerStatistic { + const { from, to, days } = resolveRange(range); + // Scale period metrics vs the 30-day base so 90/365 days show larger period totals + const factor = days / DEFAULT_SPAN_DAYS; + + return { + period: { from, to }, + currency: 'CHF', + totals: { + volume: { + buy: scaleNum(214_850.4, factor), + sell: scaleNum(22_310.75, factor), + swap: scaleNum(8_640.2, factor), + total: scaleNum(245_801.35, factor), + }, + transactions: { + buy: Math.round(1_842 * factor), + sell: Math.round(312 * factor), + swap: Math.round(96 * factor), + total: Math.round(2_250 * factor), + }, + averageTransactionVolume: 109.25, + activeUsers: Math.round(1_286 * Math.min(factor, 2)), + newUsers: Math.round(214 * Math.min(factor, 2)), + }, + allTime: { + volume: { + buy: 11_858_002.52, + sell: 855_027.41, + total: 12_713_029.93, + }, + registeredUsers: 126_547, + tradingUsers: 24_360, + }, + breakdown: { + assets: [ + { + name: 'BTC', + blockchain: 'Bitcoin', + direction: 'Buy', + volume: scaleNum(98_420.5, factor), + transactions: Math.round(620 * factor), + }, + { + name: 'ETH', + blockchain: 'Ethereum', + direction: 'Buy', + volume: scaleNum(54_210.3, factor), + transactions: Math.round(410 * factor), + }, + { + name: 'USDT', + blockchain: 'Ethereum', + direction: 'Buy', + volume: scaleNum(28_100.0, factor), + transactions: Math.round(280 * factor), + }, + { + name: 'XMR', + blockchain: 'Monero', + direction: 'Buy', + volume: scaleNum(18_450.6, factor), + transactions: Math.round(190 * factor), + }, + { + name: 'BTC', + blockchain: 'Bitcoin', + direction: 'Sell', + volume: scaleNum(12_800.4, factor), + transactions: Math.round(145 * factor), + }, + { + name: 'LTC', + blockchain: 'Litecoin', + direction: 'Buy', + volume: scaleNum(8_920.0, factor), + transactions: Math.round(95 * factor), + }, + { + name: 'ZEC', + blockchain: 'Zcash', + direction: 'Buy', + volume: scaleNum(2_640.8, factor), + transactions: Math.round(31 * factor), + }, + { + name: 'ETH', + blockchain: 'Ethereum', + direction: 'Sell', + volume: scaleNum(5_210.35, factor), + transactions: Math.round(88 * factor), + }, + { + name: 'SOL', + blockchain: 'Solana', + direction: 'Swap', + volume: scaleNum(4_120.0, factor), + transactions: Math.round(52 * factor), + }, + { + name: 'BNB', + blockchain: 'BinanceSmartChain', + direction: 'Buy', + volume: scaleNum(3_890.2, factor), + transactions: Math.round(48 * factor), + }, + ], + fiatCurrencies: [ + { + name: 'CHF', + volume: scaleNum(142_000.0, factor), + transactions: Math.round(1_120 * factor), + }, + { + name: 'EUR', + volume: scaleNum(78_500.5, factor), + transactions: Math.round(780 * factor), + }, + { + // No USD activity in this period — the row must not render (dashboard drops it). + name: 'USD', + volume: 0, + transactions: 0, + }, + ], + blockchains: [ + { + name: 'Bitcoin', + volume: scaleNum(111_220.9, factor), + transactions: Math.round(765 * factor), + }, + { + name: 'Ethereum', + volume: scaleNum(87_520.65, factor), + transactions: Math.round(778 * factor), + }, + { + name: 'Monero', + volume: scaleNum(18_450.6, factor), + transactions: Math.round(190 * factor), + }, + { + name: 'Litecoin', + volume: scaleNum(8_920.0, factor), + transactions: Math.round(95 * factor), + }, + { + name: 'Solana', + volume: scaleNum(4_120.0, factor), + transactions: Math.round(52 * factor), + }, + { + name: 'BinanceSmartChain', + volume: scaleNum(3_890.2, factor), + transactions: Math.round(48 * factor), + }, + ], + paymentMethods: [ + { + name: 'Bank', + volume: scaleNum(156_400.0, factor), + transactions: Math.round(1_380 * factor), + }, + { + // No card payments in this period — the row must not render (dashboard drops it). + name: 'Card', + volume: 0, + transactions: 0, + }, + { + name: 'OnChain', + volume: scaleNum(37_301.0, factor), + transactions: Math.round(350 * factor), + }, + ], + }, + referral: { + volume: scaleNum(42_180.5, factor), + creditEarned: scaleNum(1_265.4, factor), + creditPaid: scaleNum(980.0, factor), + creditOpen: scaleNum(285.4, factor), + currency: 'EUR', + }, + meta: { + generatedAt: '2026-07-01T08:00:00.000Z', + }, + }; +} + +const DAY_PATTERN: Array<{ buy: number; sell: number; swap: number }> = [ + { buy: 6200, sell: 710, swap: 240 }, + { buy: 7100, sell: 820, swap: 310 }, + { buy: 5800, sell: 640, swap: 180 }, + { buy: 8400, sell: 910, swap: 420 }, + { buy: 0, sell: 0, swap: 0 }, // real zero day (index 4 in pattern; we also force index 5) + { buy: 9200, sell: 1050, swap: 380 }, + { buy: 7800, sell: 880, swap: 290 }, + { buy: 6500, sell: 720, swap: 210 }, + { buy: 10100, sell: 1120, swap: 450 }, + { buy: 8700, sell: 940, swap: 330 }, + { buy: 7300, sell: 800, swap: 270 }, + { buy: 9600, sell: 1080, swap: 400 }, +]; + +/** Index relative to a 30-day window that tests pin for real-zero behaviour. */ +const ZERO_DAY_INDEX = 5; + +function stepDaysFor(granularity: PartnerGranularity): number { + switch (granularity) { + case 'Week': + return 7; + case 'Month': + return 30; + case 'Day': + default: + return 1; + } +} + +function makeBucket( + dateIso: string, + volume: { buy: number; sell: number; swap: number }, + transactions: { buy: number; sell: number; swap: number }, + opts: { partial?: boolean }, +): PartnerTimelineBucket { + return { + date: dateIso, + volume, + transactions, + partial: opts.partial === true, + }; +} + +/** + * Timeline fixture that honours requested range + granularity. + * + * - Bucket count grows with the window (30 / 90 / 365 days → distinct series lengths). + * - Edge buckets are partial. One real-zero bucket is preserved (at a fixed offset from + * the start when the series is long enough) to keep the null-vs-0 distinction visible. + * - Coarser granularity thins the series (week ≈ every 7 days, month ≈ every 30). + */ +export function buildPartnerTimelineFixture( + granularity: PartnerGranularity = 'Day', + range?: FixtureRange, +): PartnerTimeline { + const { from, to, days } = resolveRange(range); + const step = stepDaysFor(granularity); + const bucketCount = Math.max(1, Math.ceil(days / step)); + // Per-bucket amplitude scales with step so week/month totals stay comparable to day sum + const amplitude = step; + + const fromStart = startOfUtcDay(new Date(from)); + const buckets: PartnerTimelineBucket[] = []; + + for (let i = 0; i < bucketCount; i++) { + const bucketDate = new Date(fromStart); + bucketDate.setUTCDate(bucketDate.getUTCDate() + i * step); + const dateIso = isoAtUtcMidnight(bucketDate); + + // Day: fixed offset (index 5 zero) — tests pin this. + // Week/Month: distinct mid-series slot so the marker survives coarse thinning. + let zeroIdx = ZERO_DAY_INDEX; + if (granularity !== 'Day') { + zeroIdx = bucketCount >= 3 ? 1 : -1; + } + const isZero = i === zeroIdx; + + if (isZero) { + buckets.push( + makeBucket( + dateIso, + { buy: 0, sell: 0, swap: 0 }, + { buy: 0, sell: 0, swap: 0 }, + { partial: i === 0 || i === bucketCount - 1 }, + ), + ); + continue; + } + + const base = DAY_PATTERN[i % DAY_PATTERN.length]; + const scale = (0.85 + (i % 5) * 0.05) * amplitude; + const volume = { + buy: round2(base.buy * scale), + sell: round2(base.sell * scale), + swap: round2(base.swap * scale), + }; + const transactions = { + buy: Math.round((base.buy / 110) * scale), + sell: Math.round((base.sell / 70) * scale), + swap: Math.round((base.swap / 90) * scale), + }; + buckets.push( + makeBucket(dateIso, volume, transactions, { + partial: i === 0 || i === bucketCount - 1, + }), + ); + } + + // Guarantee edges are partial even if the zero bucket landed there. + // bucketCount = Math.max(1, …) ensures at least one push above. + buckets[0] = { ...buckets[0], partial: true }; + buckets[buckets.length - 1] = { ...buckets[buckets.length - 1], partial: true }; + + return { + period: { from, to }, + currency: 'CHF', + granularity, + buckets, + meta: { + generatedAt: '2026-07-01T08:00:00.000Z', + }, + }; +} diff --git a/src/partner-dashboard/styles/partner.css b/src/partner-dashboard/styles/partner.css new file mode 100644 index 000000000..00b2d876a --- /dev/null +++ b/src/partner-dashboard/styles/partner.css @@ -0,0 +1,229 @@ +/** + * Partner dashboard surface styles. + * Colours, radii, shadows and type come from DFX Design Pod tokens + * (tokens.css → --bg, --surface, --card, --text, --primary, --sp-*, --r-*, --sh-*, --font-sans). + * Do not hard-code pod hex values here — reference the variables. + */ + +@import './tokens.css'; + +.partner-dashboard { + /* Fill the app content column (flex parent uses items-center; stretch width) + * and at least the viewport so the theme surface closes over the app chrome + * background — especially visible in dark theme. Colour stays on this root only. */ + flex: 1 1 auto; + align-self: stretch; + min-height: 100%; + min-height: 100vh; + width: 100%; + box-sizing: border-box; + overflow-x: hidden; + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); +} + +.partner-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: var(--sh-sm); + padding: var(--sp-4); +} + +.partner-card-tight { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--r-md); + box-shadow: var(--sh-sm); + padding: var(--sp-3); +} + +.partner-text-secondary { + color: var(--text-secondary); +} + +.partner-text-tertiary { + color: var(--text-tertiary); +} + +.partner-muted-fill { + background: var(--surface-2, var(--surface)); +} + +.partner-track { + background: var(--surface); + border: 1px solid var(--border); +} + +.partner-gap-bar { + background: color-mix(in srgb, var(--surface) 50%, transparent); + border: 1px dashed var(--border); +} + +.partner-btn-active { + background: var(--primary); + color: var(--primary-text); +} + +.partner-btn-idle { + background: var(--surface); + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.partner-btn-idle:hover { + color: var(--text); +} + +.partner-btn-primary { + background: var(--primary); + color: var(--primary-text); + border-radius: var(--r-sm); +} + +.partner-btn-primary:hover { + background: var(--primary-hover); +} + +.partner-tooltip { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: var(--r-sm); + box-shadow: var(--sh-md); +} + +.partner-table-border { + border-color: var(--border); +} + +/* Header: title left, secondary controls right as one quiet group */ +.partner-header { + width: 100%; +} + +.partner-header-row { + display: flex; + flex-wrap: nowrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-3); + min-width: 0; +} + +.partner-header-copy { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--sp-1); +} + +.partner-header-controls { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--sp-2); + flex: 0 0 auto; + /* Keep controls on one line; never steal the title's first row alone */ + max-width: 100%; +} + +/** + * Segmented secondary control — not a primary CTA. + * Active state is readable without colour alone (weight + surface inset + + * hairline ring); idle stays tertiary so numbers keep visual priority. + */ +.partner-switcher { + display: inline-flex; + align-items: center; + gap: 1px; + padding: 2px; + border-radius: var(--r-pill); + border: 1px solid var(--border); + background: var(--surface); + height: 2rem; /* 32px — equal height across both switchers */ + box-sizing: border-box; +} + +.partner-switcher button { + appearance: none; + border: 0; + background: transparent; + color: var(--text-tertiary); + font-size: var(--fs-xs); + font-weight: var(--fw-medium, 500); + letter-spacing: 0.04em; + /* Touch-friendly min size; compact enough for narrow headers */ + min-width: 2rem; + min-height: calc(2rem - 4px); + padding: 0 0.55rem; + border-radius: var(--r-pill); + line-height: 1; + cursor: pointer; + white-space: nowrap; +} + +/* Active: weight + inset surface + hairline — not accent fill */ +.partner-switcher button[aria-pressed='true'] { + background: var(--surface-2, var(--surface)); + color: var(--text); + font-weight: var(--fw-bold, 700); + box-shadow: inset 0 0 0 1px var(--border-strong, var(--border)); +} + +.partner-switcher button:hover:not([aria-pressed='true']) { + color: var(--text-secondary); +} + +.partner-switcher button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +.partner-fixture-badge { + background: var(--warning); + color: var(--n-0); +} + +.partner-input { + appearance: none; + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + border-radius: var(--r-sm); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-size: var(--fs-sm, 0.875rem); + line-height: 1.4; +} + +.partner-input:focus { + outline: 2px solid var(--primary); + outline-offset: 1px; +} + +.partner-input:disabled { + opacity: 0.6; +} + +/* Monochrome partner logos (SVG with currentColor) rendered as . */ +.partner-logo-img { + height: 2.25rem; + width: auto; + display: block; +} + +.theme-light .partner-logo-img { + filter: brightness(0); +} + +.theme-dark .partner-logo-img { + filter: brightness(0) invert(1); +} + +.partner-error-text { + color: var(--danger, #f5516c); +} diff --git a/src/partner-dashboard/styles/tokens.css b/src/partner-dashboard/styles/tokens.css new file mode 100644 index 000000000..886331eb0 --- /dev/null +++ b/src/partner-dashboard/styles/tokens.css @@ -0,0 +1,51 @@ +/* DFX Design Pod — compiled CSS custom properties + * Source of truth: ../tokens/primitives.json + themes.json + * Consumed by: website (joshua.dfx.swiss), letterhead, email (dark hero), any web surface. + * Usage: or class="theme-light"; reference var(--bg) etc. + * Change a value here ONLY by editing the JSON tokens and recompiling — do not hand-edit consumers. + */ + +:root { + /* ---- primitives (theme-agnostic) ---- */ + --navy-900:#081e3a; --navy-800:#0a3055; --navy-700:#0b3560; --navy-600:#0d4070; + --navy-550:#0a4080; --navy-500:#1d3555; --navy-400:#475776; + --red-500:#f5516c; --red-600:#e73955; --red-glow:rgba(245,81,108,.18); --red-soft:rgba(245,81,108,.10); + --blue-500:#1e6ef7; --blue-600:#0b57cf; --blue-50:#e6f0ff; + --n-0:#fff; --n-25:#fbfcff; --n-50:#f6f8fc; --n-100:#eef3fa; --n-150:#eef2f7; + --n-200:#dde5f0; --n-500:#8d98aa; --n-600:#566174; --n-900:#0b1426; --n-1000:#000; + --dt-high:#f9fafb; --dt-muted:#a8b5c8; --dt-dim:#8a99b7; --dt-faint:#5a6d85; + --success:#16a34a; --warning:#eab308; --error:#dc2626; --info:#2f7cf7; + + /* ---- shared brand + type ---- */ + --brand-navy:var(--navy-800); --brand-accent:var(--red-500); --brand-accent-hover:var(--red-600); + --font-sans:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif; + --font-mono:'Source Code Pro',ui-monospace,SFMono-Regular,Menlo,monospace; + --fw-light:300; --fw-regular:400; --fw-medium:500; --fw-semibold:600; --fw-bold:700; --fw-black:900; + --fs-xs:12px; --fs-sm:14px; --fs-base:16px; --fs-lg:20px; --fs-xl:26px; --fs-2xl:30px; --fs-3xl:40px; --fs-4xl:56px; + --sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px; --sp-7:48px; --sp-8:64px; --sp-9:96px; + --r-sm:6px; --r-md:10px; --r-lg:16px; --r-xl:24px; --r-pill:999px; + --sh-sm:0 1px 2px rgba(11,20,38,.06); --sh-md:0 6px 20px rgba(11,20,38,.10); --sh-red:0 8px 28px rgba(245,81,108,.28); +} + +/* ===== dark theme — web / letterhead / email hero ===== */ +.theme-dark { + --bg:var(--navy-800); --bg-deep:var(--navy-900); + --surface:var(--navy-600); --surface-2:var(--navy-500); + --card:linear-gradient(145deg,rgba(29,53,85,.80),rgba(13,64,112,.50)); + --card-hover:linear-gradient(145deg,rgba(29,53,85,.95),rgba(13,64,112,.70)); + --border:rgba(255,255,255,.08); --border-strong:rgba(255,255,255,.12); + --text:var(--dt-high); --text-secondary:var(--dt-muted); --text-tertiary:var(--dt-dim); --text-faint:var(--dt-faint); + --primary:var(--red-500); --primary-hover:var(--red-600); --primary-text:var(--n-0); --cta-glow:var(--red-glow); + --nav-bar:linear-gradient(180deg,#0b3560,#0a3055); + color-scheme:dark; +} + +/* ===== light theme — DFX Wallet / app surfaces ===== */ +.theme-light { + --bg:var(--n-50); --surface:var(--n-0); --surface-2:var(--n-100); --surface-raised:var(--n-25); + --card:var(--n-0); --border:var(--n-200); --border-light:var(--n-150); + --text:var(--n-900); --text-secondary:var(--n-600); --text-tertiary:var(--n-500); + --primary:var(--blue-500); --primary-hover:var(--blue-600); --primary-text:var(--n-0); --primary-soft:var(--blue-50); + --accent:var(--red-500); --accent-hover:var(--red-600); + color-scheme:light; +} diff --git a/src/partner-dashboard/util/chart-theme.ts b/src/partner-dashboard/util/chart-theme.ts new file mode 100644 index 000000000..a6de42d77 --- /dev/null +++ b/src/partner-dashboard/util/chart-theme.ts @@ -0,0 +1,72 @@ +import { ApexOptions } from 'apexcharts'; +import { PartnerTheme, readThemeCssVar, SERIES_COLORS_BY_THEME } from './theme'; + +/** Resolve chart chrome colours from the design-pod CSS variables for `theme`. */ +export function chartChromeColors(theme: PartnerTheme): { + grid: string; + axis: string; + legend: string; + mode: 'light' | 'dark'; + series: [string, string, string]; +} { + const series = SERIES_COLORS_BY_THEME[theme]; + const grid = + readThemeCssVar('--border', theme) || + (theme === 'dark' ? 'rgba(255,255,255,0.08)' : '#dde5f0'); + const axis = + readThemeCssVar('--text-tertiary', theme) || + readThemeCssVar('--text-secondary', theme) || + (theme === 'dark' ? '#8a99b7' : '#566174'); + const legend = + readThemeCssVar('--text', theme) || (theme === 'dark' ? '#f9fafb' : '#0b1426'); + return { + grid, + axis, + legend, + mode: theme, + series: [series.buy, series.sell, series.swap], + }; +} + +/** Shared Apex base options that follow the partner theme. */ +export function baseChartOptions(theme: PartnerTheme): Pick< + ApexOptions, + 'chart' | 'theme' | 'grid' | 'xaxis' | 'legend' | 'tooltip' | 'colors' | 'stroke' | 'dataLabels' | 'markers' | 'fill' +> { + const chrome = chartChromeColors(theme); + return { + chart: { + type: 'area', + stacked: true, + toolbar: { show: false }, + background: '0', + zoom: { enabled: false }, + animations: { enabled: false }, + fontFamily: readThemeCssVar('--font-sans', theme) || undefined, + }, + theme: { mode: chrome.mode }, + stroke: { width: 1.5, curve: 'smooth' }, + colors: chrome.series, + dataLabels: { enabled: false }, + fill: { type: 'solid', opacity: theme === 'dark' ? 0.55 : 0.4 }, + grid: { borderColor: chrome.grid, strokeDashArray: 3 }, + xaxis: { + type: 'datetime', + labels: { datetimeUTC: false, style: { colors: chrome.axis } }, + axisBorder: { color: chrome.grid }, + axisTicks: { color: chrome.grid }, + }, + legend: { + position: 'top', + horizontalAlign: 'left', + labels: { colors: chrome.legend }, + }, + tooltip: { + shared: true, + intersect: false, + theme: chrome.mode, + x: { format: 'dd MMM yyyy' }, + }, + markers: { size: 0, hover: { size: 4 } }, + }; +} diff --git a/src/partner-dashboard/util/format.ts b/src/partner-dashboard/util/format.ts new file mode 100644 index 000000000..103fc86e4 --- /dev/null +++ b/src/partner-dashboard/util/format.ts @@ -0,0 +1,94 @@ +/** + * Display formatters for the partner dashboard. + * null means the value is genuinely absent (e.g. no transactions to average), 0 means a + * real zero — never conflate them. + * Locale follows the active UI language (default: en-US, repo base language). + */ + +import { getPartnerLocale } from './i18n'; + +/** Neutral placeholder when a value is not available. */ +export const ABSENT_LABEL = '–'; + +/** Amount with thousands separators and currency code, e.g. "123'456.78 CHF". */ +export function formatAmount( + value: number | null | undefined, + currency: string, + fractionDigits = 2, + locale: string = getPartnerLocale(), +): string { + if (value == null) return ''; + const formatted = value.toLocaleString(locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }); + return `${formatted} ${currency}`; +} + +/** Whole-number amount (KPIs), still with currency. */ +export function formatAmountWhole( + value: number | null | undefined, + currency: string, + locale: string = getPartnerLocale(), +): string { + return formatAmount(value, currency, 0, locale); +} + +/** Count with thousands separators. */ +export function formatCount( + value: number | null | undefined, + locale: string = getPartnerLocale(), +): string { + if (value == null) return ''; + return value.toLocaleString(locale, { maximumFractionDigits: 0 }); +} + +/** Rate 0..1 → "12.3 %". */ +export function formatPercent( + rate: number | null | undefined, + fractionDigits = 1, + locale: string = getPartnerLocale(), +): string { + if (rate == null) return ''; + const pct = rate * 100; + return `${pct.toLocaleString(locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + })} %`; +} + +/** + * Trading-conversion rate: tradingUsers ÷ registeredUsers, as a 0..1 fraction for + * formatPercent(). Undefined (not 0, not NaN, not Infinity) when there are no + * registered users yet (brand-new partner, no installs) — that state is deliberately + * distinct from "0 % conversion", which would claim installs exist and none convert. + */ +export function computeConversionRate( + tradingUsers: number, + registeredUsers: number, +): number | null { + if (registeredUsers <= 0) return null; + return tradingUsers / registeredUsers; +} + +export type DisplayValue = + | { kind: 'value'; text: string } + | { kind: 'absent'; text: string } + | { kind: 'empty'; text: string }; + +export function displayNullable( + value: number | null | undefined, + format: (n: number) => string, +): DisplayValue { + if (value === null) { + return { kind: 'absent', text: '–' }; + } + if (value === undefined) { + return { kind: 'empty', text: '–' }; + } + return { kind: 'value', text: format(value) }; +} + +export function isFixtureMode(): boolean { + return process.env.REACT_APP_PARTNER_FIXTURE === 'true'; +} diff --git a/src/partner-dashboard/util/i18n.ts b/src/partner-dashboard/util/i18n.ts new file mode 100644 index 000000000..4735b7342 --- /dev/null +++ b/src/partner-dashboard/util/i18n.ts @@ -0,0 +1,59 @@ +import i18n from 'i18next'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** Same mapping as settings.context — partner labels follow the main-app language. */ +const languageToLocale: Record = { + en: 'en-US', + de: 'de-CH', + fr: 'fr-FR', + it: 'it-IT', +}; + +export const PARTNER_NS = 'screens/partner'; + +/** + * No-op retained only because the temporarily swapped `src/index.tsx` still + * imports it. Language follows the main-app i18n; partner-local storage is gone. + */ +export function applyStoredPartnerLanguage(): void { + // intentionally empty +} + +/** + * Translate under `screens/partner.`. + * Language is the main-app i18n language (no partner-local storage override). + */ +export function partnerTranslate( + defaultValue: string, + interpolation?: Record, +): string { + return i18n.t([PARTNER_NS, defaultValue].join('.'), defaultValue, interpolation); +} + +export function getPartnerLocale(lang?: string): string { + const raw = lang ?? i18n.language ?? 'en'; + const base = raw.split('-')[0]?.toLowerCase() ?? 'en'; + return languageToLocale[base] ?? 'en-US'; +} + +export function usePartnerTranslation(): { + translate: (defaultValue: string, interpolation?: Record) => string; + locale: string; + language: string; +} { + const { t, i18n: i18nInstance } = useTranslation(); + + const translate = useCallback( + (defaultValue: string, interpolation?: Record) => + t([PARTNER_NS, defaultValue].join('.'), defaultValue, interpolation), + [t], + ); + + const locale = getPartnerLocale(i18nInstance.language); + return { + translate, + locale, + language: i18nInstance.language, + }; +} diff --git a/src/partner-dashboard/util/series.ts b/src/partner-dashboard/util/series.ts new file mode 100644 index 000000000..af5e73346 --- /dev/null +++ b/src/partner-dashboard/util/series.ts @@ -0,0 +1,78 @@ +import { PartnerDirectionField, PartnerTimelineBucket } from 'src/dto/partner-statistic.dto'; +import { PartnerTheme, SEQUENTIAL_BAR_COLORS_BY_THEME } from './theme'; + +/** + * Build ApexCharts series points for chart geometry. + * + * The API contract guarantees every bucket carries a real `volume`/`transactions` + * group for every direction (days without activity are filled with real zeros + * server-side) — there is no "absent" case to represent. A thin day is a low + * point on the curve; a day with no activity is the zero point. Never a hole. + * The return type has no `null` so a gap cannot be reintroduced here by accident. + */ +export function timelineSeries( + buckets: PartnerTimelineBucket[], + field: 'volume' | 'transactions', + direction: PartnerDirectionField, +): Array<[number, number]> { + return buckets.map((bucket) => { + const t = new Date(bucket.date).getTime(); + return [t, bucket[field][direction]]; + }); +} + +/** @deprecated Prefer sequentialColor(index, total, theme). Dark palette fallback. */ +export const SEQUENTIAL_BAR_COLORS = SEQUENTIAL_BAR_COLORS_BY_THEME.dark; + +export function sequentialColor( + index: number, + total: number, + theme: PartnerTheme = 'dark', +): string { + const palette = SEQUENTIAL_BAR_COLORS_BY_THEME[theme]; + if (total <= 1) return palette[0]; + // Cycle the contrast-safe palette so long lists never fall below 3:1. + return palette[index % palette.length]; +} + +export interface NamedVolumeRow { + name: string; + volume: number; + transactions: number; +} + +/** + * A row is unused (no activity at all) only when both volume and transactions are + * exactly zero. A zero-volume row with real transactions (e.g. free transfers) or a + * zero-transaction row with volume is still real activity and must not be dropped. + */ +export function hasActivity(row: Pick): boolean { + return row.volume !== 0 || row.transactions !== 0; +} + +/** + * Sort descending by volume. Aggregate the tail into one "other" row when the list + * exceeds `maxItems`, then re-sort so a large aggregate does not sit last and + * inflate `maxVolume` for the bar scale. + * + * `otherLabel` is supplied by the caller so it can go through i18n (default English + * base key "Other" — never hard-code a locale-specific word here). + */ +export function rankNamedVolumes( + rows: NamedVolumeRow[], + maxItems = 12, + otherLabel = 'Other', +): NamedVolumeRow[] { + const sorted = [...rows].sort((a, b) => b.volume - a.volume); + if (sorted.length <= maxItems) return sorted; + const head = sorted.slice(0, maxItems - 1); + const tail = sorted.slice(maxItems - 1); + let vol = 0; + let tx = 0; + for (const row of tail) { + vol += row.volume; + tx += row.transactions; + } + head.push({ name: otherLabel, volume: vol, transactions: tx }); + return head.sort((a, b) => b.volume - a.volume); +} diff --git a/src/partner-dashboard/util/theme.ts b/src/partner-dashboard/util/theme.ts new file mode 100644 index 000000000..4bcec9f22 --- /dev/null +++ b/src/partner-dashboard/util/theme.ts @@ -0,0 +1,134 @@ +import { useCallback, useState } from 'react'; + +export type PartnerTheme = 'light' | 'dark'; + +/** localStorage key — dashboard-only; does not affect main-app chrome. */ +export const PARTNER_THEME_STORAGE_KEY = 'partner-dashboard-theme'; + +/** Series colours validated for each surface (dataviz skill). Not from the design pod. */ +export const SERIES_COLORS_BY_THEME: Record< + PartnerTheme, + { buy: string; sell: string; swap: string } +> = { + light: { + buy: '#1e6ef7', + sell: '#0f9b8e', + swap: '#8b5cf6', + }, + dark: { + buy: '#3f86fb', + sell: '#19a08f', + swap: '#9a7bf2', + }, +}; + +/** + * Sequential bar shades — cool blue family, theme-aware. + * Each step is ≥ 3:1 contrast against `--surface` (WCAG 1.4.11 non-text UI): + * light surface #ffffff: 4.54 / 3.68 / 3.27 / 3.08 + * dark surface #0d4070: 3.03 / 3.78 / 4.74 / 5.87 + * Longer lists cycle this set (see sequentialColor) rather than fading past the floor. + */ +export const SEQUENTIAL_BAR_COLORS_BY_THEME: Record = { + light: ['#1e6ef7', '#3b82f6', '#4f8cf7', '#5e90f7'], + dark: ['#3f86fb', '#5a9cf5', '#7eb0f5', '#a0c4f0'], +}; + +export function readStoredTheme(): PartnerTheme | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(PARTNER_THEME_STORAGE_KEY); + if (raw === 'light' || raw === 'dark') return raw; + } catch { + // ignore storage errors (private mode, quota, …) + } + return null; +} + +/** + * Partner dashboard defaults to light when nothing is stored. + * Deliberate product choice: do not follow prefers-color-scheme (even if the + * OS is dark) so first paint stays light until the user picks a theme. + * Scope is the partner root only — main app has no dark mode. + */ +export function resolveInitialTheme(): PartnerTheme { + return readStoredTheme() ?? 'light'; +} + +export function persistTheme(theme: PartnerTheme): void { + try { + window.localStorage.setItem(PARTNER_THEME_STORAGE_KEY, theme); + } catch { + // ignore + } +} + +/** Theme class names applied on the partner root (and probe elements). */ +export function themeClassName(theme: PartnerTheme): string { + return theme === 'light' ? 'theme-light' : 'theme-dark'; +} + +/** Read a CSS custom property from the partner root (or documentElement). */ +export function readCssVar(name: string, el?: Element | null): string { + const target = + el ?? + (typeof document !== 'undefined' + ? document.getElementById('partner-dashboard-root') ?? document.documentElement + : null); + if (!target || typeof getComputedStyle === 'undefined') return ''; + return getComputedStyle(target).getPropertyValue(name).trim(); +} + +/** + * Read a pod CSS variable for a given theme — never from a stale host class. + * Chart options are built during React render (useMemo); resolve against a probe + * that carries the requested theme class when the live root is not ready. + */ +export function readThemeCssVar(name: string, theme: PartnerTheme): string { + if (typeof document === 'undefined' || typeof getComputedStyle === 'undefined') return ''; + + const expected = themeClassName(theme); + const root = document.getElementById('partner-dashboard-root'); + if (root?.classList.contains(expected)) { + return getComputedStyle(root).getPropertyValue(name).trim(); + } + + const probe = document.createElement('div'); + probe.className = expected; + probe.setAttribute('aria-hidden', 'true'); + probe.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;pointer-events:none'; + document.documentElement.appendChild(probe); + try { + return getComputedStyle(probe).getPropertyValue(name).trim(); + } finally { + probe.remove(); + } +} + +/** + * Dashboard-local theme state. Persists across remounts via localStorage. + * Classes are applied only on `#partner-dashboard-root` by the caller — + * never on documentElement — so the main app stays light. + */ +export function usePartnerTheme(): { + theme: PartnerTheme; + setTheme: (theme: PartnerTheme) => void; + toggleTheme: () => void; +} { + const [theme, setThemeState] = useState(() => resolveInitialTheme()); + + const setTheme = useCallback((next: PartnerTheme) => { + setThemeState(next); + persistTheme(next); + }, []); + + const toggleTheme = useCallback(() => { + setThemeState((prev) => { + const next: PartnerTheme = prev === 'dark' ? 'light' : 'dark'; + persistTheme(next); + return next; + }); + }, []); + + return { theme, setTheme, toggleTheme }; +} diff --git a/src/partner-dashboard/util/timeline-axis.ts b/src/partner-dashboard/util/timeline-axis.ts new file mode 100644 index 000000000..70f42fbbe --- /dev/null +++ b/src/partner-dashboard/util/timeline-axis.ts @@ -0,0 +1,126 @@ +import { ApexOptions } from 'apexcharts'; +import { PartnerGranularity, PartnerTimelineBucket } from 'src/dto/partner-statistic.dto'; + +/** + * Shared X-axis tick strategy for both partner timeline charts. + * + * One pure selection of bucket indices (first + last always included, ~6–8 + * evenly spaced mid points) so volume and transactions charts stay aligned. + * Labels sit on real buckets — not on Apex-invented intermediate timestamps. + */ + +/** Target label count from chart width (px). Independent of bucket count. */ +export function targetTimelineTickCount(chartWidthPx = 960): number { + if (chartWidthPx < 400) return 4; + if (chartWidthPx < 640) return 5; + if (chartWidthPx < 900) return 7; + return 8; +} + +/** + * Evenly spaced bucket indices including endpoints. + * Returns sorted unique indices in `[0, bucketCount)`. + */ +export function selectTimelineTickIndices(bucketCount: number, maxTicks: number): number[] { + if (bucketCount <= 0) return []; + if (bucketCount === 1) return [0]; + + const count = Math.max(2, Math.min(Math.floor(maxTicks), bucketCount)); + if (count === 2) return [0, bucketCount - 1]; + + const indices = new Set(); + for (let i = 0; i < count; i++) { + indices.add(Math.round((i * (bucketCount - 1)) / (count - 1))); + } + indices.add(0); + indices.add(bucketCount - 1); + return Array.from(indices).sort((a, b) => a - b); +} + +/** Format a bucket date for the axis, following API granularity + active locale. */ +export function formatTimelineTick( + isoOrTs: string | number, + granularity: PartnerGranularity, + locale: string, +): string { + const d = new Date(isoOrTs); + if (Number.isNaN(d.getTime())) return ''; + + switch (granularity) { + case 'Month': + return d.toLocaleDateString(locale, { month: 'short', year: 'numeric' }); + case 'Week': + // Bucket date is the week start — day + month is the week label. + return d.toLocaleDateString(locale, { day: 'numeric', month: 'short' }); + case 'Day': + default: + return d.toLocaleDateString(locale, { day: 'numeric', month: 'short' }); + } +} + +export interface TimelineXAxisParams { + buckets: PartnerTimelineBucket[]; + granularity: PartnerGranularity; + locale: string; + /** Axis label colour (from chart chrome). */ + axisColor: string; + /** Optional measured chart width; defaults to a desktop card width. */ + chartWidthPx?: number; +} + +/** + * Apex x-axis + grid slice used by both timeline charts. + * Category axis so ticks land on real buckets; only selected indices get text. + * + * Categories stay as ISO dates (annotation keys / series positions). Display text + * is precomputed via `overwriteCategories` so we do not depend on Apex's + * formatter `opts.i` (unreliable across Apex versions for category axes). + */ +export function buildTimelineXAxis(params: TimelineXAxisParams): Pick { + const { buckets, granularity, locale, axisColor } = params; + const maxTicks = targetTimelineTickCount(params.chartWidthPx ?? 960); + const tickIndices = selectTimelineTickIndices(buckets.length, maxTicks); + const tickSet = new Set(tickIndices); + const categories = buckets.map((b) => b.date); + const overwriteCategories = buckets.map((b, i) => + tickSet.has(i) ? formatTimelineTick(b.date, granularity, locale) : '', + ); + + return { + xaxis: { + type: 'category', + categories, + overwriteCategories, + tickPlacement: 'on', + labels: { + show: true, + rotate: 0, + rotateAlways: false, + hideOverlappingLabels: true, + showDuplicates: false, + trim: false, + style: { colors: axisColor, fontSize: '11px' }, + // Fallback if overwriteCategories is ignored: map by category ISO value. + formatter: (value: string) => { + // When Apex passes the overwritten label, keep it; when it passes ISO, format. + const idx = categories.indexOf(value); + if (idx >= 0) { + return tickSet.has(idx) ? formatTimelineTick(value, granularity, locale) : ''; + } + return value; + }, + }, + // Dense category axes would otherwise draw a tick per bucket (30–365). + axisTicks: { show: false }, + }, + // Vertical grid per category would be noise on long ranges; y-grid stays. + grid: { + xaxis: { lines: { show: false } }, + }, + }; +} + +/** Y values only — pair with category x-axis from {@link buildTimelineXAxis}. */ +export function timelineSeriesValues(points: Array<[number, number]>): number[] { + return points.map(([, y]) => y); +} diff --git a/src/screens/partner-dashboard.screen.tsx b/src/screens/partner-dashboard.screen.tsx new file mode 100644 index 000000000..901996bf4 --- /dev/null +++ b/src/screens/partner-dashboard.screen.tsx @@ -0,0 +1,30 @@ +import { usePartnerDashboardGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import PartnerDashboardView from 'src/partner-dashboard/App'; + +/** + * Partner dashboard as a main-app screen — role guard first, then layout frame, + * then the presentation view. + * + * No layout `title`: the dashboard header owns the page title (program kicker + + * h1). Passing a title here would stack a second headline under the app bar. + * `backButton: false` keeps the burger/menu chrome; `noMaxWidth` + `noPadding` + * let the dashboard’s own `max-w-7xl` container and padding own the frame + * (same idea as Support Dashboard full-bleed tools). + * + * Route: `/partner/dashboard` (mirrors `/support/dashboard` staff-tool path shape). + */ +export default function PartnerDashboardScreen(): JSX.Element { + usePartnerDashboardGuard(); + + useLayoutOptions({ + backButton: false, + noMaxWidth: true, + noPadding: true, + // Layout default is text-center; the dashboard is a data surface with + // left-aligned labels over left-aligned figures (16 other screens do the same). + textStart: true, + }); + + return ; +} diff --git a/src/setupTests.ts b/src/setupTests.ts index aab39bb56..65b1e753b 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -4,6 +4,8 @@ // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; import { configure } from '@testing-library/react'; +import i18n from 'i18next'; +import { setupLanguages } from './translations'; // Jest defaults to maxWorkers = CPU cores - 1 (17 workers on 18 cores); under that load a // worker can go without CPU time for over a second at times. @@ -19,3 +21,9 @@ configure({ asyncUtilTimeout: 5000 }); // 5000 ms" and no diagnostic detail. react-scripts does not accept testTimeout in the jest // section of package.json (supportedKeys in createJestConfig.js omits it), so set it here. jest.setTimeout(15000); + + +// Partner dashboard (and any useTranslation consumer) needs the same i18n init as the app. +// Force English so tests assert on the English base keys/defaults, not the host browser language. +setupLanguages(); +void i18n.changeLanguage('en'); diff --git a/src/translations/index.ts b/src/translations/index.ts index dc50d0e50..fadc037bf 100644 --- a/src/translations/index.ts +++ b/src/translations/index.ts @@ -6,15 +6,21 @@ import fr from './languages/fr.json'; import it from './languages/it.json'; export function setupLanguages() { + if (i18n.isInitialized) return; + i18n .use(initReactI18next) .use(LanguageDetector) .init({ resources: { + // English UI strings are the keys themselves (defaultValue); empty + // bundle keeps fallbackLng / changeLanguage('en') valid for i18next. + en: { translation: {} }, de: { translation: de }, fr: { translation: fr }, it: { translation: it }, }, + fallbackLng: 'en', interpolation: { escapeValue: false, }, diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json index ef9bfec00..560759581 100644 --- a/src/translations/languages/de.json +++ b/src/translations/languages/de.json @@ -71,9 +71,7 @@ "Allowed formats: PDF, JPG, JPEG, PNG": "Erlaubte Formate: PDF, JPG, JPEG, PNG", "Invalid date format": "Ungültiges Datumsformat", "Maximum allowed characters: 4000": "Maximal erlaubte Zeichen: 4000", - "No entries yet": "Noch keine Einträge vorhanden", - "Something went wrong. Please try again. If the issue persists please reach out to our support.": "Irgendwas hat nicht funktioniert, bitte versuche es noch einmal. Wenn das Problem weiterhin besteht, wende Dich bitte an unseren Support.", "invoice": "DFX kennt keinen Empfänger mit dem Namen {{recipient}}. Dieser Service kann nur für Empfänger verwendet werden, die ein aktives Konto bei DFX haben und für den Rechnungsservice aktiviert sind. Solltest Du Dich als Empfänger bei DFX registrieren wollen, dann melde Dich beim Support unter {{supportLink}}.", "iban": "Dies ist eine Multi-Account-IBAN und kann nicht als persönliches Konto hinzugefügt werden. Bitte öffne ein Support-Ticket auf <1> und füge die Bestätigung der Banktransaktion als PDF bei." @@ -92,15 +90,12 @@ "KYC progress": "KYC-Fortschritt", "This step has already been finished.": "Dieser Schritt ist bereits abgeschlossen.", "This step has failed.": "Dieser Schritt ist fehlgeschlagen.", - "per 24h": "pro 24h", "per 30 days": "pro 30 Tage", "per year": "pro Jahr", - "Terminated": "Beendet", "Rejected": "Abgelehnt", "Level {{level}}": "Level {{level}}", - "Contact data": "Kontaktdaten", "Personal data": "Persönliche Daten", "Identification": "Identifikation", @@ -120,7 +115,6 @@ "Residence permit": "Aufenthaltsbewilligung", "Association statutes": "Vereinsstatuten", "DFX approval": "DFX-Genehmigung", - "Stock corporation (AG, Ltd, SA)": "Aktiengesellschaft (AG)", "Limited liability company under Swiss/German/Austrian law (GmbH, LLC, Sàrl)": "Gesellschaft mit beschränkter Haftung (GmbH)", "Entrepreneurial company (UG)": "Unternehmergesellschaft (UG)", @@ -143,25 +137,19 @@ "Simple and flexible form of cooperation between two or more people who join forces for a common purpose": "Einfache und flexible Form der Zusammenarbeit zwischen zwei oder mehr Personen, die sich für einen gemeinsamen Zweck zusammenschließen", "An internet excerpt is sufficient. No notarization is required. The extract must not be older than 2 months.": "Ein Internetauszug ist ausreichend. Eine notarielle Beglaubigung ist nicht erforderlich. Der Auszug darf nicht älter als 2 Monate sein.", "Document template": "Dokument-Vorlage", - "Female": "Weiblich", "Male": "Männlich", - "Birthday": "Geburtstag", "YYYY-MM-DD": "JJJJ-MM-TT", - "Passport": "Pass", "ID card": "ID-Karte", "Driver's license": "Führerschein", - "Authorized to sign individually": "Einzelunterschriftberechtigt", "Authorized to sign jointly": "Unterschriftsberechtigt zu zweit", "No signing authorization": "Keine Unterschriftsberechtigung", - "auto": "auto", "video": "video", "manual": "manuell", - "Not started": "Nicht gestartet", "In progress": "In Bearbeitung", "In review": "In Prüfung", @@ -169,9 +157,7 @@ "Failed": "Gescheitert", "Outdated": "Veraltet", "Data requested": "Daten angefordert", - "Optional": "Optional", - "Account Type": "Kontotyp", "Personal": "Privatkonto", "Organization / company": "Organisation / Firma", @@ -210,21 +196,17 @@ "Identification document": "Identifikationsdokument", "Document type": "Dokumenttyp", "Document number": "Dokumentnummer", - "Sole proprietorship confirmation": "Bestätigung als Einzelunternehmen", "Commercial register extract, trade license/permit, AHV confirmation, or another document proving the existence of the business": "Handelsregisterauszug, Gewerbeschein/-bewilligung, AHV-Bestätigung, oder ein anderes Dokument welches die Existenz des Gewerbes nachweist", - "Please fill in personal information to continue": "Bitte gib Deine persönlichen Daten ein, um fortzufahren", "How did you hear about DFX?": "Wie bist Du auf DFX aufmerksam geworden?", "Please enter your contact information so that we can find your account": "Bitte gib Deine Kontaktinformationen ein, damit wir Dein Konto finden können", - "How many natural persons are there who directly or indirectly hold 25% or more of company shares?": "Wie viele natürliche Personen gibt es, die direkt oder indirekt 25 % oder mehr der Unternehmensanteile halten?", "None": "Keine", "Is the account holder {{name}}{{role}}?": "Ist der Kontoinhaber {{name}}{{role}}?", "the managing director": "der Geschäftsführer", "a beneficial owner": "ein wirtschaftlich Berechtigter", "Managing director": "Geschäftsführer", - "It looks like you already have an account with DFX.": "Wie es aussieht, hast Du bereits ein Konto bei DFX.", "We have just sent you an email. To continue with your existing account, please confirm your email address by clicking on the link sent.": "Wir haben Dir gerade eine E-Mail geschickt. Um mit Deinem bestehenden Konto fortzufahren, bestätige bitte Deine E-Mail-Adresse, indem Du auf den zugesandten Link klickst.", "We have sent a 6-digit code to your new email address. Please enter it here.": "Wir haben einen 6-stelligen Code an Deine neue E-Mail-Adresse gesendet. Bitte gib ihn hier ein.", @@ -255,34 +237,28 @@ "I am already verified with DFX": "Ich bin bereits bei DFX verifiziert", "I hereby authorize DFX to transfer my KYC data to {{client}}.": "Hiermit beauftrage ich DFX, meine KYC-Daten an {{client}} zu übermitteln.", "The identification has failed.": "Die Identifizierung ist fehlgeschlagen.", - "Merging your accounts...": "Deine Konten werden zusammengeführt...", "Account merged successfully!": "Konto erfolgreich zusammengeführt!", "You can now access your account.": "Du kannst jetzt auf Dein Konto zugreifen.", "My account": "Mein Konto", - "Upload your SEPA file here": "Lade Deine SEPA-Datei hier hoch", "Uploaded": "Hochgeladen", - "KYC file": "KYC Datei", "Name check": "Namensprüfung", "User information": "Benutzerinformationen", "User notes": "Benutzernotizen", "Transaction notes": "Transaktionsnotizen", "Stock register": "Aktienregister", - "File download": "Datei-Download", "Data upload": "Data Upload", "UserData ID": "UserData ID", "UserData IDs": "UserData IDs", "Event date": "Datum des Ereignisses", "Comment": "Kommentar", - "Is the organization operationally active?": "Ist die Organisation operativ tätig?", "Organizations that primarily manage their own money, such as investment companies, are considered non-operating. Operationally active organizations are those that offer and sell goods or services, conduct regular business activities that generate revenue, employ staff and have operational structures.": "Organisationen, die in erster Linie ihr eigenes Geld verwalten, wie z. B. Investmentgesellschaften, gelten als nicht-operativ. Operativ tätige Organisationen sind solche, die Waren oder Dienstleistungen anbieten und verkaufen, regelmäßige Geschäftstätigkeiten durchführen, die Einnahmen generieren, Personal beschäftigen und über betriebliche Strukturen verfügen.", "Organization website (optional)": "Website der Organisation (optional)", "https://my-organization.org": "https://meine-firma.org", - "Purpose of the payments": "Zweck der Zahlungen", "Purpose": "Zweck", "Assignment agreement": "Forderungsabtretungsvertrag (Zession)", @@ -292,12 +268,10 @@ "My organization": "Meine Firma", "Registration number": "Registrierungsnummer", "CHE-000.000.000": "CHE-000.000.000", - "Store type": "Geschäftstyp", "Online": "Online", "Physical": "Physisch", "Online and physical": "Online und physisch", - "Merchant category": "Händlerkategorie", "Accommodation and food services": "Beherbergung und Gastronomie", "Administrative support & waste management": "Verwaltungsdienstleistungen & Abfallwirtschaft", @@ -344,11 +318,9 @@ "Transportation & warehousing": "Transport & Lagerung", "Utilities": "Versorgungsunternehmen", "Other crypto/Web3 services": "Andere Krypto-/Web3-Dienste", - "Goods type": "Warenart", "Tangible": "Physisch", "Virtual": "Virtuell", - "Goods category": "Warenkategorie", "Electronics & computers": "Elektronik & Computer", "Books, music & movies": "Bücher, Musik & Filme", @@ -366,18 +338,14 @@ "Food, grocery & health products": "Lebensmittel, Supermarkt & Gesundheitsprodukte", "Pet supplies": "Tierbedarf", "Industry & science": "Industrie & Wissenschaft", - "Recall agreement": "Rückrufvereinbarung", - "ownFundsWarning": "Die <1>vorsätzliche Angabe falscher Informationen in diesem Formular ist eine strafbare Handlung (Urkundenfälschung gemäss Artikel 251 des Schweizerischen Strafgesetzbuchs).", - "Recommendation": "Empfehlung", "Please enter the email address or referral code of your contact person. This lets us know you have a trusted contact to guide you into the crypto space.": "Gib bitte die E-Mail-Adresse oder den Empfehlungs-/Referenzcode Deiner Ansprechperson ein. Damit wissen wir, dass Du einen vertrauenswürdigen Kontakt hast, der Dich beim Einstieg ins Kryptoumfeld begleitet.", "Invitation/referral code or email": "Einladungs-/Referral-Code oder E-Mail", "No matching user found": "Kein passender Benutzer gefunden", "Invalid invitation code": "Ungültiger Einladungscode", "Invalid key": "Ungültiger Key", - "FAQ": "FAQ", "How can I become a DFX customer?": "Wie kann ich Kunde bei DFX werden?", "Opening an account with DFX is only possible through a referral. You need either the ref code, ref link, or email address of an existing customer. This person serves as your point of contact and must additionally confirm their referral. Once this confirmation is received, you can complete your onboarding and use your DFX account.": "Eine Kontoeröffnung bei DFX ist ausschliesslich über Empfehlung möglich. Du benötigst dafür entweder den Ref-Code, den Ref-Link oder die E-Mail-Adresse eines Bestandskunden. Diese Person dient Dir als Ansprechstelle und muss ihre Empfehlung zusätzlich bestätigen. Sobald diese Bestätigung vorliegt, kannst Du Dein Onboarding vollständig abschliessen und Deinen DFX Account nutzen.", @@ -482,7 +450,6 @@ }, "screens/error": { "Oh, sorry, something went wrong": "Entschuldigung, irgendwas hat nicht funktioniert", - "Invalid link": "Ungültiger Link", "Merge is already completed": "Zusammenführung ist bereits abgeschlossen", "Please return to the previous page. If this problem persists, please contact our support.": "Bitte kehre zur vorherigen Seite zurück. Wenn dieses Problem weiterhin besteht, wende Dich bitte an unseren Support.", @@ -495,12 +462,9 @@ "Coming Soon": "Coming Soon", "Login to DFX Services": "Login bei DFX Services", "We have sent an email with further instructions to the address provided.": "Wir haben eine E-Mail mit weiteren Anweisungen an die angegebene Adresse gesendet.", - "Logging in...": "Anmelden...", - "Scan with your wallet": "Scanne mit Deiner Wallet", "Connect your wallet": "Verbinde Deine Wallet", - "Please confirm the connection in your MetaMask.": "Bitte bestätige die Verbindung in Deiner MetaMask.", "Please confirm the connection in the Alby browser extension.": "Bitte bestätige die Verbindung in der Alby Browsererweiterung.", "Please confirm the connection in your Phantom browser extension.": "Bitte bestätige die Verbindung in der Phantom Browsererweiterung.", @@ -510,37 +474,29 @@ "Please confirm the connection with your Ledger.": "Bitte bestätige die Verbindung mit Deinem Ledger.", "Connection failed!": "Verbindung fehlgeschlagen!", "Please make sure that all other applications and browser extensions that are connected to the ledger are completely closed when you try to connect.\nThis includes third-party wallets (Ledger Live, Metamask, Daedalus, MyEtherWallet) or other applications that could interfere with the connection between DFX.swiss and your ledger device.": "Bitte stelle sicher, dass alle anderen Anwendungen und Browsererweiterungen, die mit dem Ledger verbunden sind, vollständig geschlossen sind, wenn Du versuchst, eine Verbindung herzustellen.\nDazu gehören Wallets von Drittanbietern (Ledger Live, Metamask, Daedalus, MyEtherWallet) oder andere Anwendungen, die die Verbindung zwischen DFX.swiss und Deinem Ledger-Gerät stören könnten.", - "Connect your {{device}} with your computer": "Verbinde Deinen {{device}} mit Deinem Computer", "Click on \"Connect\"": "Klicke auf \"Verbinden\"", "Click on \"Continue\"": "Klicke auf \"Weiter\"", "Confirm \"Sign message\" on your {{device}}": "Bestätige \"Sign message\" auf Deinem {{device}}", - "Load more addresses": "Lade mehr Adressen", "Address type": "Adresstyp", "Address index": "Adressindex", "Account index": "Accountindex", - "Open the {{app}} app on your Ledger": "Öffne die {{app}} App auf Deinem Ledger", - "Enter your password on your BitBox": "Gib Dein Passwort auf Deinem BitBox ein", "Confirm the pairing code": "Bestätige den Pairing-Code", "Pairing code": "Pairing-Code", "Check that the pairing code below matches the one displayed on your BitBox": "Prüfe, ob der untenstehende Pairing-Code mit dem auf Deinem BitBox angezeigten übereinstimmt", "Confirm the pairing code on your BitBox": "Bestätige den Pairing-Code auf Deinem BitBox", - "Click on \"Continue in Trezor Connect\"": "Klicke auf \"Weiter zu Trezor Connect\"", "Follow the steps in the Trezor Connect website": "Führe die Schritte in der Trezor Connect Webseite aus", - "Login with your BTC Taro Wallet": "Anmeldung mit Deiner BTC Taro Wallet", "Install BTC Taro": "BTC Taro installieren", "Pay in app": "Bezahlen in der App", "Open app and scan QR code again": "App öffnen und QR Code erneut scannen", "App temporarily unavailable": "App vorübergehend nicht verfügbar", - "Log in to your DFX account by verifying with your signature that you are the sole owner of the provided blockchain address.": "Melde Dich bei Deinem DFX-Konto an, indem Du mit Deiner Unterschrift bestätigst, dass Du der alleinige Eigentümer der angegebenen Blockchain-Adresse bist.", "Don't show this again.": "Nicht mehr anzeigen.", - "Please install MetaMask or Rabby!": "Bitte installiere MetaMask oder Rabby!", "You need to install the MetaMask or Rabby browser extension to be able to use this service.": "Um diesen Dienst nutzen zu können, musst Du die Browsererweiterung MetaMask oder Rabby installieren.", "Please install Alby!": "Bitte installiere Alby!", @@ -555,26 +511,21 @@ "Mobile not supported!": "Mobile nicht unterstützt!", "Please use a desktop device with a compatible browser (e.g. Chrome) to be able to use this service.": "Bitte verwende ein Desktop-Gerät mit einem kompatiblen Browser (z.B. Chrome), um diesen Dienst nutzen zu können.", "visit": "Siehe <1> für mehr Details.", - "Please install {{wallet}} from the wallet provider's website.": "Bitte installiere {{wallet}} von der Website des Wallet-Anbieters.", "Open website": "Website öffnen", - "Unfortunately, DFX.swiss does not offer the purchase and sale of {{token}} tokens.": "Leider bietet DFX.swiss den Kauf und Verkauf von {{token}} Token nicht an.", "If you are still interested, you can visit their website.": "Falls Du dennoch Interesse hast, kannst Du ihre Website besuchen.", "private": "Falls Du dennoch Interesse hast, kannst Du die Website <2> über den unten stehenden Button besuchen.", "The website allows you to interact with the {{name}} smart contract and trade {{symbol}} tokens. Please note that this is not an offer from DFX, it is not a recommendation and it is in no way a solicitation to trade. With this message DFX only points out that this technical possibility exists, nothing more. It is imperative that you inform yourself about all the details beforehand and always be aware that you are always trading on your own responsibility.": "Die Website ermöglicht, mit dem {{name}} Smart Contract zu interagieren und {{symbol}} Token zu handeln. Bitte beachte, dass dies kein Angebot von DFX ist, dass es keine Empfehlung ist und dass es in keinster Weise eine Handelsaufforderung ist. DFX weist mit dieser Nachricht einzig darauf hin, dass es diese technische Möglichkeit gibt, mehr nicht. Informiere Dich zwingend vorher über alle Details und sei Dir stehts bewusst, dass Du immer in eigener Verantwortung handelst.", - "Blockchain": "Blockchain", "Address": "Adresse", "Signature": "Signatur", "Sign message": "Nachricht zum Signieren", "Instructions": "Anleitung", - "Account": "Konto", "Profile": "Profil", "Email": "E-Mail", "Name": "Name", - "Address": "Adresse", "Organization": "Organisation", "Activity": "Aktivität", "Active address": "Aktive Adresse", @@ -592,12 +543,9 @@ "Paid credit": "Ausgezahltes Guthaben", "User count": "Anzahl Benutzer", "Active user count": "Anzahl aktiver Benutzer", - "Please select an address or add a new one to continue.": "Bitte wähle eine Adresse aus oder füge eine neue hinzu, um fortzufahren.", - "PDF Download Address Report": "PDF Adressbericht herunterladen", "Date": "Datum", - "Change phone number": "Telefonnummer ändern", "Change address": "Adresse ändern", "Change name": "Name ändern", @@ -609,52 +557,38 @@ "You spend": "Du zahlst", "You get": "Du erhältst", "You get about": "Du erhältst ungefähr", - "Target address": "Ziel-Adresse", "Switch address": "Adresse wechseln", "Login with a different address": "Mit einer anderen Adresse anmelden", "Are you sure you want to send to a different address?": "Bist Du sicher, dass Du auf eine andere Adresse senden willst?", - "Use {{chain}} as a Layer 2 solution to benefit from lower transaction fees": "Verwende {{chain}} als Layer-2-Lösung, um von niedrigeren Transaktionsgebühren zu profitieren", - "Done!": "Fertig!", "Click here once you have issued the transfer": "Klicke hier, sobald Du die Überweisung getätigt hast", - "Please transfer the purchase amount using this information via your banking application. The remittance info is important!": "Bitte überweise den Kaufbetrag mit diesen Angaben über Deine Bankanwendung. Der Verwendungszweck ist wichtig!", "Please transfer the purchase amount using this information via your banking application. This IBAN is unique to this asset, no remittance info is required.": "Bitte überweise den Kaufbetrag mit diesen Angaben über Deine Bankanwendung. Dieser IBAN ist einzigartig für dieses Asset, kein Verwendungszweck erforderlich.", - "The remittance info remains identical for the selected asset and can be used for recurring payments and standing orders": "Der Verwendungszweck bleibt für das ausgewählte Asset identisch und kann für wiederkehrende Zahlungen und Daueraufträge verwendet werden", - "Name": "Name", "Address": "Adresse", - "As soon as the transfer arrives in our bank account, we will transfer your asset to your wallet.": "Sobald die Überweisung auf unserem Bankkonto eingegangen ist, werden wir das Asset in Deine Wallet übertragen.", "Waiting for the payment confirmation ... this may take a moment.": "Warten auf die Zahlungsbestätigung ... das kann einen Moment dauern.", - "PDF Invoice": "PDF-Rechnung", "QR-bill": "QR-Rechnung", - "The output amount is computed as the input amount minus the DFX fee, bank fee and the network fee over the base rate. That is, {{output}} {{outputSymbol}} = ({{input}} {{inputSymbol}} - {{dfxFee}} {{feeSymbol}} - {{bankFee}} {{feeSymbol}} - {{networkFee}} {{feeSymbol}}) ÷ {{baseRate}}.": "Der Ausgabebetrag wird als Eingabebetrag abzüglich der DFX-Gebühr, Bankgebühr und Netzwerkgebühr über dem Basiskurs berechnet. Das heißt, {{output}} {{outputSymbol}} = ({{input}} {{inputSymbol}} - {{dfxFee}} {{feeSymbol}} - {{bankFee}} {{feeSymbol}} - {{networkFee}} {{feeSymbol}}) ÷ {{baseRate}}.", "Output amount = (Input amount - DFX fee - Network fee) ÷ Base rate.": "Ausgabebetrag = (Eingabebetrag - DFX-Gebühr - Netzwerkgebühr) ÷ Basiskurs." }, "screens/sell": { "Add or select your IBAN": "Deine IBAN hinzufügen oder auswählen", "Select payment account": "Bankkonto auswählen", - "Send the selected amount to the address below. This address can be used multiple times, it is always the same for payouts from {{chain}} to your IBAN {{iban}} in {{currency}}.": "Sende den ausgewählten Betrag an die untenstehende Adresse. Diese Adresse kann mehrfach verwendet werden, sie ist immer die gleiche für Auszahlungen von {{chain}} an Deine IBAN {{iban}} in {{currency}}.", "Address": "Adresse", - "Click here once you have issued the transaction": "Klicke hier, sobald Du die Transaktion getätigt hast", "Complete transaction in your wallet": "Schliesse die Transaktion in Deiner Wallet ab", "Pay with your wallet": "Bezahle mit Deiner Wallet", "Transaction hash": "Transaktions-Hash", "Transaction failed. Click Retry to see the deposit address for manual transfer.": "Transaktion fehlgeschlagen. Klicke auf Wiederholen, um die Einzahlungsadresse für eine manuelle Überweisung zu sehen.", - "Optional - Account Designation": "Optional - Kontobezeichnung", "e.g. Deutsche Bank": "z.B. Deutsche Bank", - "As soon as the transaction arrives in our wallet, we will transfer your money to your bank account.": "Sobald die Transaktion in unserem Wallet eingegangen ist, werden wir Dein Guthaben auf Dein Bankkonto überweisen.", - "The output amount is computed as the input amount times the base rate minus the DFX fee, bank fee and the network fee. That is, {{output}} {{inputSymbol}} = {{input}} {{outputSymbol}} × {{baseRate}} - {{dfxFee}} {{feeSymbol}} - {{bankFee}} {{feeSymbol}} - {{networkFee}} {{feeSymbol}}.": "Der Ausgabebetrag wird als Eingabebetrag multipliziert mit dem Basiskurs abzüglich der DFX-Gebühr, Bankgebühr und Netzwerkgebühr berechnet. Das heißt, {{output}} {{inputSymbol}} = {{input}} {{outputSymbol}} × {{baseRate}} - {{dfxFee}} {{feeSymbol}} - {{bankFee}} {{feeSymbol}} - {{networkFee}} {{feeSymbol}}.", "Output amount = Input amount × Base rate - DFX fee - Network fee.": "Ausgabebetrag = Eingabebetrag × Basiskurs - DFX-Gebühr - Netzwerkgebühr." }, @@ -704,7 +638,6 @@ "This bank no longer accepts payments. Please start a new purchase.": "Diese Bank akzeptiert keine Zahlungen mehr. Bitte starte einen neuen Kauf.", "The selected currency is not available. Please try a different currency or contact support.": "Die gewählte Währung ist nicht verfügbar. Bitte wähle eine andere Währung oder kontaktiere den Support.", "No bank is available for this currency. Please try a different currency or contact support.": "Für diese Währung ist keine Bank verfügbar. Bitte wähle eine andere Währung oder kontaktiere den Support.", - "Exchange rate": "Wechselkurs", "Base rate": "Basiskurs", "Total fee": "Gesamtgebühr", @@ -715,9 +648,7 @@ "Bank fee (fixed)": "Bankgebühr (fix)", "Bank fee (percent)": "Bankgebühr (prozentual)", "Network start fee": "Netzwerk-Startgebühr", - "Your payment has failed. Please try again.": "Deine Zahlung ist fehlgeschlagen. Bitte versuche es erneut.", - "Transactions": "Transaktionen", "Transaction": "Transaktion", "Your Transactions": "Deine Transaktionen", @@ -728,7 +659,6 @@ "Transaction refund": "Rückerstattung der Transaktion", "{{from}} to {{to}} at {{price}} {{from}}/{{to}} ({{source}}, {{timestamp}})": "{{from}} zu {{to}} bei {{price}} {{from}}/{{to}} ({{source}}, {{timestamp}})", "No transactions yet": "Noch keine Transaktionen", - "ID": "ID", "External ID": "Externe ID", "Date": "Datum", @@ -739,7 +669,6 @@ "Output 2": "Output 2", "TX": "TX", "Native coin to cover future transaction fees": "Native Coins zur Deckung künftiger Transaktionsgebühren", - "Chargeback IBAN": "Chargeback-IBAN", "Chargeback address": "Chargeback-Adresse", "Chargeback amount": "Chargeback-Betrag", @@ -750,13 +679,11 @@ "Fee": "Gebühr", "Refund amount is the transaction amount minus the fee.": "Der Rückerstattungsbetrag ist der Transaktionsbetrag minus der Gebühr.", "This IBAN cannot be used for refunds. Please select a personal bank account.": "Diese IBAN kann nicht für Rückerstattungen verwendet werden. Bitte wähle ein persönliches Bankkonto.", - "Type": "Typ", "Buy": "Kauf", "Sell": "Verkauf", "Swap": "Tausch", "Staking": "Staking", - "State": "Status", "Created": "Erstellt", "Processing": "In Bearbeitung", @@ -778,9 +705,7 @@ "Liquidity pending": "Liquidität ausstehend", "Payout in progress": "Auszahlung in Bearbeitung", "Price undeterminable": "Preis nicht bestimmbar", - "LNURL decoded": "LNURL dekodiert", - "Failure reason": "Fehlerursache", "Unknown": "Unbekannt", "Annual volume": "Jährliches Volumen", @@ -814,19 +739,15 @@ "we will call you at {{phone}}": "wir werden Dich unter {{phone}} anrufen", "Bank release pending": "Bankfreigabe ausstehend", "Input not yet confirmed": "Einzahlung noch nicht bestätigt", - "Loading countries...": "Länder werden geladen...", - "Payment method": "Zahlungsmethode", "Standard bank transaction": "Standard-Banktransaktion", "Instant bank transaction": "Sofort-Banktransaktion", "Credit card": "Kreditkarte", - "Show on block explorer": "Im Block-Explorer anzeigen", "Report an issue": "Ein Problem melden", "Export CSV": "CSV-Export", "Assign transaction": "Transaktion zuordnen", - "{{from}} to {{to}} at {{price}} {{from}}/{{to}} on {{source}}": "{{from}} zu {{to}} bei {{price}} {{from}}/{{to}} auf {{source}}.", "This exchange rate is not guaranteed. The effective rate will be determined once the transactions have been received by DFX and the crypto assets can be delivered.": "Dieser Wechselkurs ist nicht garantiert. Der effektive Kurs wird ermittelt, wenn die Transaktionen bei DFX eingegangen ist und die Krypto-Assets ausgeliefert werden können.", "Please note that by using this service you automatically accept our terms and conditions. The effective exchange rate is fixed when the money is received and processed by DFX.": "Bitte beachte, dass Du mit der Nutzung dieses Dienstes automatisch unsere Allgemeinen Geschäftsbedingungen akzeptierst. Der effektive Wechselkurs wird festegelegt wenn das Geld bei DFX eingegangen ist und verarbeitet wird.", @@ -837,13 +758,11 @@ "Nice! You are all set! Give us a minute to handle your transaction.": "Sehr schön! Alles erledigt! Gib uns eine Minute, um Deine Transaktion zu bearbeiten.", "We will inform you by email about the progress of your transactions.": "Wir werden Dich per E-Mail über den Fortschritt Deiner Transaktionen informieren.", "Enter your email address if you want to be informed about the progress of your transactions": "Gib Deine E-Mail Adresse ein, wenn Du über den Fortschritt Deiner Transaktionen informiert werden willst", - "The exchange rate of {{rate}} {{currency}}/{{asset}} is fixed for {{timer}}, after which it will be recalculated.": "Der Wechselkurs von {{rate}} {{currency}}/{{asset}} ist für {{timer}} festgelegt, danach wird er neu berechnet.", "Please send the specified amount to the address below.": "Bitte sende den angegebenen Betrag an die unten stehende Adresse.", "Please note that by using this service you automatically accept our terms and conditions.": "Bitte beachte, dass Du mit der Nutzung dieses Dienstes automatisch unsere Allgemeinen Geschäftsbedingungen akzeptierst.", "By using this service, the outstanding claim of the above-mentioned company against DFX is assigned, and the General Terms and Conditions of DFX AG apply.": "Mit der Nutzung dieses Dienstes wird die offene Forderung des oben genannten Unternehmen an DFX abgetreten und es gelten die Allgemeinen Geschäftsbedingungen der DFX AG.", "Learn more about OpenCryptoPay": "Mehr über OpenCryptoPay erfahren", - "Payment routes": "Payment Routen", "Payment route": "Payment Route", "Purpose of payment": "Zahlungszweck", @@ -870,11 +789,9 @@ "Deactivate": "Deaktivieren", "Expires at": "Gültig bis", "Public": "Öffentlich", - "Payment Methods": "Zahlungsmethoden", "Supported cryptocurrencies and blockchains": "Unterstützte Kryptowährungen und Blockchains", "Locations": "Standorte", - "Configuration": "Konfiguration", "Default configuration": "Standardkonfiguration", "Payment standards": "Zahlungsstandards", @@ -882,26 +799,21 @@ "Display QR code": "QR-Code anzeigen", "Payment timeout (seconds)": "Zahlungs-Timeout (Sekunden)", "Payment cancellable": "Zahlung stornierbar", - "Actual": "Aktuell", "Transaction received": "Transaktion erhalten", "Transaction in mempool": "Transaktion im Mempool", "Transaction in blockchain": "Transaktion in der Blockchain", "Transaction completed": "Transaktion abgeschlossen", "Transaction failed": "Transaktion fehlgeschlagen", - "Token contract": "Token-Contract", - "Create Invoice": "Rechnung erstellen", "Invoice ID": "Rechnungs-ID", "Recipient not found": "Empfänger nicht gefunden", - "Route": "Route", "External IDs (comma separated)": "Externe IDs (kommagetrennt)", "Summary": "Zusammenfassung", "N/A": "N/A", "delete": "Bist Du sicher, dass Du Deine <1>{{type}}-Zahlungsroute mit der <1>ID {{id}} löschen möchtest?", - "Payment details": "Zahlungsdetails", "Your payment details at a glance": "Deine Zahlungsdetails auf einen Blick", "{{blockchain}} address": "{{blockchain}} Adresse", @@ -915,7 +827,6 @@ "Compatible apps": "Kompatible Apps", "Semi compatible apps": "Teilweise kompatible Apps", "Invalid Payment Link": "Ungültiger Payment Link", - "Cointracking": "Cointracking", "Compact": "Kompakt", "Chain-Report": "Chain-Report", @@ -945,10 +856,8 @@ "Latest transactions": "Letzte Transaktionen", "Create Payment": "Zahlung erstellen", "Copy POS link": "POS-Link kopieren", - "Assign payment link '{{id}}'": "Payment Link '{{id}}' zuweisen", "Assign": "Zuweisen", - "Total amount": "Gesamtbetrag", "You will be redirected to the site shortly": "Du wirst in Kürze zur Seite weitergeleitet", "Go back to the payment page": "Zurück zur Zahlungsseite", @@ -1002,10 +911,8 @@ "Support tickets": "Support-Tickets", "View tickets": "Tickets anzeigen", "The existing EUR IBAN (CH8583019DFXSWISSEURX) is currently experiencing technical issues. Please use your personal IBAN for EUR transactions instead. You can find your personal IBAN on the Buy page.": "Die bestehende EUR-IBAN (CH8583019DFXSWISSEURX) hat derzeit technische Probleme. Bitte verwende stattdessen deine persönliche IBAN für EUR-Transaktionen. Du findest deine persönliche IBAN auf der Kaufseite.", - "Support issue": "Supportanfrage", "The issue has been successfully submitted. You will be contacted by email.": "Die Anfrage wurde erfolgreich eingereicht. Du wirst per E-Mail kontaktiert.", - "Issue type": "Art der Anfrage", "Generic issue": "Allgemeine Anfrage", "Transaction issue": "Transaktionsbezogene Anfrage", @@ -1015,7 +922,6 @@ "Notification of changes": "Mitteilung von Änderungen", "Bug report": "Fehlermeldung", "Verification call": "Verifizierungsanruf", - "Reason": "Grund", "Other": "Andere", "Data request": "Datenanfrage", @@ -1026,16 +932,12 @@ "Name changed": "Name geändert", "Address changed": "Adresse geändert", "Civil status changed": "Zivilstand geändert", - "contactDataChangeHint": "Name, Adresse, Telefonnummer und E-Mail-Adresse können direkt in Deinem <2> geändert werden.", - "Transaction ID": "Transaktions-ID", "Select a transaction to proceed with": "Wähle eine Transaktion aus, um fortzufahren", - "Name": "Name", "Description": "Beschreibung", "File": "Datei", - "Select the transaction for which you would like to create an issue.": "Wähle die Transaktion aus, für die Du eine Anfrage erstellen möchtest.", "Please provide us with all relevant information about the transaction you are missing.": "Bitte gib uns alle relevanten Informationen über die fehlende Transaktion an.", "Sender IBAN": "Absender-IBAN", @@ -1046,19 +948,16 @@ "Please log in so that we can also check your personal IBAN.": "Bitte melde Dich an, damit wir auch Deine persönliche IBAN prüfen können.", "We could not check this IBAN at the moment. You can submit your request anyway.": "Wir konnten diese IBAN gerade nicht prüfen. Du kannst Deine Anfrage trotzdem absenden.", "Date of the transaction": "Datum der Transaktion", - "FAQ": "FAQ", "We have summarized the most common questions for you in our FAQ.": "Wir haben die häufigsten Fragen für Dich in unseren FAQs zusammengefasst.", "Search now": "Jetzt suchen", "Submit Ticket": "Ticket einreichen", "Contact us": "Kontaktiere uns", "If you have a specific question or problem, you can submit a ticket here.": "Wenn Du eine spezifische Frage oder ein Problem hast, kannst Du hier ein Ticket einreichen.", - "Write a message...": "Schreibe eine Nachricht...", "Downloading...": "Herunterladen...", "Image": "Bild", "Document": "Dokument", - "Created on": "Erstellt am", "Hide completed tickets": "Abgeschlossene Tickets ausblenden", "Show completed tickets": "Abgeschlossene Tickets anzeigen" @@ -1086,14 +985,11 @@ "Label": "Bezeichnung", "Address name": "Adressname", "Default": "Standard", - "Show deleted addresses": "Gelöschte Adressen anzeigen", "Hide deleted addresses": "Gelöschte Adressen ausblenden", - "delete": "Bist Du sicher, dass Du die Adresse <1>{{address}} von Deinem DFX-Konto löschen möchtest? Diese Aktion ist nicht rückgängig zu machen.", "delete_iban": "Bist Du sicher, dass Du das Bankkonto <1>{{address}} von Deinem DFX-Konto löschen möchtest?", "Your data will remain on our servers temporarily before permanent deletion. If you have any questions, please contact our support team.": "Deine Daten verbleiben vorübergehend auf unseren Servern, bevor sie dauerhaft gelöscht werden. Wenn Du Fragen hast, wende Dich bitte an unser Support-Team.", - "Verification Call": "Verifizierungsanruf", "Preferred call time": "Bevorzugte Anrufzeit", "Morning": "Vormittag", @@ -1103,7 +999,6 @@ "No, don't call me": "Nein, ruft mich nicht an", "Verification may require a phone call. Should we call you?": "Die Verifizierung kann einen Anruf erfordern. Sollen wir Dich anrufen?", "Phone verification": "Telefonische Verifizierung", - "Danger Zone": "Gefahrenzone" }, "screens/safe": { @@ -1230,5 +1125,63 @@ "Your personal IBAN for {{currency}} transactions is now available. Future bank transfers will be made to this IBAN in your own name.": "Deine persönliche IBAN für {{currency}} Transaktionen ist jetzt verfügbar. Zukünftige Banküberweisungen werden auf diese IBAN auf Deinen Namen getätigt.", "To generate a personal IBAN, we need some additional information from you. Please complete the verification process.": "Um eine persönliche IBAN zu generieren, benötigen wir einige zusätzliche Informationen von Dir. Bitte schliesse den Verifizierungsprozess ab.", "Complete verification": "Verifizierung abschliessen" + }, + "screens/partner": { + "Non-Custodial Partner Program": "Non-Custodial Partnerprogramm", + "NC Partner Program": "NC Partner Programm", + "Total volume": "Gesamtvolumen", + "This period": "Dieser Zeitraum", + "All-time totals": "Gesamtwerte", + "Transactions": "Vorgänge", + "Average transaction size": "Ø-Vorgangsgröße", + "Active users": "Aktive Nutzer", + "New users": "Neue Nutzer", + "Registered users (total)": "Registrierte Nutzer (gesamt)", + "Trading users (total)": "Handelnde Nutzer (gesamt)", + "Lifetime volume": "Volumen (Lebenszeit)", + "of registered users": "der registrierten Nutzer", + "No registered users yet": "Noch keine registrierten Nutzer", + "Volume by cryptocurrency": "Volumen je Kryptowährung", + "Fiat currencies": "Fiat-Währungen", + "Blockchains": "Blockchains", + "Payment methods": "Zahlungswege", + "Partner metrics could not be loaded.": "Die Partner-Kennzahlen konnten nicht geladen werden.", + "Demo data": "Demodaten", + "30 days": "30 Tage", + "90 days": "90 Tage", + "365 days": "365 Tage", + "Day": "Tag", + "Week": "Woche", + "Month": "Monat", + "Period and granularity": "Zeitraum und Granularität", + "Period": "Zeitraum", + "Granularity": "Granularität", + "Volume over time": "Volumen über Zeit", + "No volume data for the selected period.": "Keine Volumendaten im gewählten Zeitraum.", + "No transaction data for the selected period.": "Keine Vorgangsdaten im gewählten Zeitraum.", + "Date": "Datum", + "No data.": "Keine Daten.", + "Other": "Sonstige", + "Show as table": "Als Tabelle anzeigen", + "Hide table": "Tabelle ausblenden", + "Referral": "Referral", + "Referral volume": "Referral-Volumen", + "Credit earned": "Gutschrift verdient", + "Credit paid out": "Gutschrift ausgezahlt", + "Credit open": "Gutschrift offen", + "Buy": "Kauf", + "Sell": "Verkauf", + "Swap": "Swap", + "An error occurred while loading the dashboard. The page remains usable.": "Beim Laden des Dashboards ist ein Fehler aufgetreten. Die Seite bleibt bedienbar.", + "Unknown error": "Unbekannter Fehler", + "Retry": "Wiederholen", + "Try again": "Erneut versuchen", + "Transactions over time": "Vorgänge über Zeit", + "Shows how much was traded in each period, split into buy, sell and swap.": "Zeigt, wie viel in jedem Zeitraum gehandelt wurde — aufgeteilt nach Kauf, Verkauf und Swap.", + "Shows how many operations happened in each period, split into buy, sell and swap.": "Zeigt, wie viele Vorgänge in jedem Zeitraum stattfanden — aufgeteilt nach Kauf, Verkauf und Swap.", + "Theme": "Darstellung", + "Light": "Hell", + "Dark": "Dunkel", + "Language": "Sprache" } } diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json index 3324e6ca0..8de1ef356 100644 --- a/src/translations/languages/fr.json +++ b/src/translations/languages/fr.json @@ -1229,5 +1229,64 @@ "Your personal IBAN for {{currency}} transactions is now available. Future bank transfers will be made to this IBAN in your own name.": "Votre IBAN personnel pour les transactions {{currency}} est maintenant disponible. Les futurs virements bancaires seront effectués sur cet IBAN à votre nom.", "To generate a personal IBAN, we need some additional information from you. Please complete the verification process.": "Pour générer un IBAN personnel, nous avons besoin de quelques informations supplémentaires. Veuillez compléter le processus de vérification.", "Complete verification": "Compléter la vérification" + }, + + "screens/partner": { + "Non-Custodial Partner Program": "Programme partenaire non dépositaire", + "NC Partner Program": "Programme partenaire NC", + "Total volume": "Volume total", + "This period": "Période sélectionnée", + "All-time totals": "Totaux cumulés", + "Transactions": "Opérations", + "Average transaction size": "Taille moyenne d’opération", + "Active users": "Utilisateurs actifs", + "New users": "Nouveaux utilisateurs", + "Registered users (total)": "Utilisateurs inscrits (total)", + "Trading users (total)": "Utilisateurs ayant négocié (total)", + "Lifetime volume": "Volume cumulé", + "of registered users": "des utilisateurs inscrits", + "No registered users yet": "Aucun utilisateur inscrit pour l’instant", + "Volume by cryptocurrency": "Volume par cryptomonnaie", + "Fiat currencies": "Monnaies fiat", + "Blockchains": "Blockchains", + "Payment methods": "Moyens de paiement", + "Partner metrics could not be loaded.": "Les indicateurs partenaire n’ont pas pu être chargés.", + "Demo data": "Données de démonstration", + "30 days": "30 jours", + "90 days": "90 jours", + "365 days": "365 jours", + "Day": "Jour", + "Week": "Semaine", + "Month": "Mois", + "Period and granularity": "Période et granularité", + "Period": "Période", + "Granularity": "Granularité", + "Volume over time": "Volume dans le temps", + "No volume data for the selected period.": "Aucune donnée de volume pour la période sélectionnée.", + "No transaction data for the selected period.": "Aucune donnée d’opération pour la période sélectionnée.", + "Date": "Date", + "No data.": "Aucune donnée.", + "Other": "Autres", + "Show as table": "Afficher en tableau", + "Hide table": "Masquer le tableau", + "Referral": "Parrainage", + "Referral volume": "Volume de parrainage", + "Credit earned": "Crédit acquis", + "Credit paid out": "Crédit versé", + "Credit open": "Crédit en attente", + "Buy": "Achat", + "Sell": "Vente", + "Swap": "Échange", + "An error occurred while loading the dashboard. The page remains usable.": "Une erreur est survenue lors du chargement du tableau de bord. La page reste utilisable.", + "Unknown error": "Erreur inconnue", + "Retry": "Réessayer", + "Try again": "Essayer à nouveau", + "Transactions over time": "Opérations dans le temps", + "Shows how much was traded in each period, split into buy, sell and swap.": "Montre le volume échangé sur chaque période, réparti entre achat, vente et échange.", + "Shows how many operations happened in each period, split into buy, sell and swap.": "Montre le nombre d’opérations sur chaque période, réparti entre achat, vente et échange.", + "Theme": "Affichage", + "Light": "Clair", + "Dark": "Sombre", + "Language": "Langue" } } diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json index 33ab045d6..c1232aea3 100644 --- a/src/translations/languages/it.json +++ b/src/translations/languages/it.json @@ -1229,5 +1229,64 @@ "Your personal IBAN for {{currency}} transactions is now available. Future bank transfers will be made to this IBAN in your own name.": "Il tuo IBAN personale per le transazioni {{currency}} è ora disponibile. I futuri bonifici bancari saranno effettuati su questo IBAN a tuo nome.", "To generate a personal IBAN, we need some additional information from you. Please complete the verification process.": "Per generare un IBAN personale, abbiamo bisogno di alcune informazioni aggiuntive. Si prega di completare il processo di verifica.", "Complete verification": "Completare la verifica" + }, + + "screens/partner": { + "Non-Custodial Partner Program": "Programma partner non custodial", + "NC Partner Program": "Programma partner NC", + "Total volume": "Volume totale", + "This period": "Periodo selezionato", + "All-time totals": "Totali complessivi", + "Transactions": "Operazioni", + "Average transaction size": "Dimensione media operazione", + "Active users": "Utenti attivi", + "New users": "Nuovi utenti", + "Registered users (total)": "Utenti registrati (totale)", + "Trading users (total)": "Utenti che hanno operato (totale)", + "Lifetime volume": "Volume complessivo", + "of registered users": "degli utenti registrati", + "No registered users yet": "Nessun utente registrato finora", + "Volume by cryptocurrency": "Volume per criptovaluta", + "Fiat currencies": "Valute fiat", + "Blockchains": "Blockchain", + "Payment methods": "Metodi di pagamento", + "Partner metrics could not be loaded.": "Non è stato possibile caricare i dati del partner.", + "Demo data": "Dati dimostrativi", + "30 days": "30 giorni", + "90 days": "90 giorni", + "365 days": "365 giorni", + "Day": "Giorno", + "Week": "Settimana", + "Month": "Mese", + "Period and granularity": "Periodo e granularità", + "Period": "Periodo", + "Granularity": "Granularità", + "Volume over time": "Volume nel tempo", + "No volume data for the selected period.": "Nessun dato di volume per il periodo selezionato.", + "No transaction data for the selected period.": "Nessun dato di operazione per il periodo selezionato.", + "Date": "Data", + "No data.": "Nessun dato.", + "Other": "Altro", + "Show as table": "Mostra come tabella", + "Hide table": "Nascondi tabella", + "Referral": "Referral", + "Referral volume": "Volume referral", + "Credit earned": "Credito maturato", + "Credit paid out": "Credito erogato", + "Credit open": "Credito da erogare", + "Buy": "Acquisto", + "Sell": "Vendita", + "Swap": "Scambio", + "An error occurred while loading the dashboard. The page remains usable.": "Si è verificato un errore durante il caricamento della dashboard. La pagina resta utilizzabile.", + "Unknown error": "Errore sconosciuto", + "Retry": "Riprova", + "Try again": "Prova di nuovo", + "Transactions over time": "Operazioni nel tempo", + "Shows how much was traded in each period, split into buy, sell and swap.": "Mostra quanto è stato scambiato in ogni periodo, suddiviso tra acquisto, vendita e scambio.", + "Shows how many operations happened in each period, split into buy, sell and swap.": "Mostra quante operazioni sono avvenute in ogni periodo, suddivise tra acquisto, vendita e scambio.", + "Theme": "Aspetto", + "Light": "Chiaro", + "Dark": "Scuro", + "Language": "Lingua" } }