Non-Custodial Partner Program — partner statistics behind the normal login - #1262
Open
joshuakrueger-dfx wants to merge 17 commits into
Open
Non-Custodial Partner Program — partner statistics behind the normal login#1262joshuakrueger-dfx wants to merge 17 commits into
joshuakrueger-dfx wants to merge 17 commits into
Conversation
Reads the aggregated partner statistics endpoints and renders them as a white-label dashboard: KPI tiles, volume and transaction time series, breakdowns by asset, currency, blockchain and payment method, and the referral position. A fixture mode carries demo data so the whole surface runs without an API or a token. Withheld values are shown as a placeholder rather than a zero, and a suppressed day is marked in the charts instead of drawing a gap — a gap reads as "no business that day", which is not what the API means by null. Built as its own entry point next to the existing app, so the shared build is untouched.
The API moved its enum values to PascalCase, so the dashboard was sending `day` where the timeline endpoint now only accepts `Day` — every real timeline call would have returned 400 — and it read `buy` from the asset breakdown where the API now writes `Buy`, so rows were sorted into nothing. Only fixture mode hid it. The values are defined once and derived from there; the JSON field names stay lowercase, since the API maps the enum onto them separately. The period buttons were wired but had nothing to act on: the demo data ignored the requested range and always returned the same month, which is why "365 days" showed thirty buckets. It now honours range and granularity, keeps the withheld buckets and the genuine zero day, and still marks the edge buckets as partial.
The partner dashboard carried its texts hardcoded in German while the rest of the repo runs on English keys with translation files. The first partner is an international product, and more are planned, so the surface was unusable as it stood. It now uses the same mechanism as everything else: English keys, the German wording moved into the existing translation file unchanged, and number, date and currency formats following the selected language rather than staying German. French and Italian fall back to English until their keys are filled in — text work, no code.
Collects the dashboard work that was sitting unsaved in a scratchpad directory: the wallet login gate and its auth hook, the per-partner brand registry with a fallback for partners without a logo, light and dark themes with a switcher, German and English with a switcher, the even-spaced timeline axis, and the table that starts scrolling past twenty rows. Committed as one unit to get it under version control. It is not yet reviewed as a whole.
The lint script globbed `*.ts` only, so every component in the app went unlinted — the whole partner dashboard among them. Extending the glob surfaces fifteen pre-existing problems in five unrelated test files; those are left as they are, to be dealt with by whoever owns them rather than buried in this change.
The API no longer withholds anything, so the dashboard stops drawing withholding. Gone are the hatched bands, the interpolation that bridged withheld days, the dash placeholders and the threshold that produced them. A day with a single transaction is now a low point on the curve, and a day with none sits on the zero line — the shape falls, it does not break. That is enforced by the type rather than by a test: the series carries `[number, number]`, so a gap cannot be expressed at all. Reintroducing one no longer compiles. The completion block went with it. It rendered `statistic.completion`, which the API does not send and never did — against the real endpoint it would have drawn from `undefined`. It belongs to a separate change that is deliberately parked, so it leaves with its types, fixtures and translations rather than waiting as scenery. Edge buckets keep their partial marker. That one is still true: the first and last bucket of a period really can extend past the period's ends.
The API has always returned two figures the dashboard never showed: how many of a wallet's registered users ever traded, and the lifetime volume behind them. The first is the number a partner actually wants — installs are easy, a first trade is not, and the ratio between them says whether their users find the way there at all. It is shown as a conversion rate beside the raw counts. A partner with no installs yet has no ratio rather than a misleading zero percent, and a test pins that the page never renders NaN there. Adding them exposed a problem that was already present. Eight tiles now sat in one six-column grid, leaving a ragged row of two — but the real fault was that they were two different kinds of number wearing the same clothes. Five move when the period selector moves; registered users, trading users and lifetime volume are lifetime counters the API computes without any date range at all. Someone switching from thirty days to a year watched five figures change and three sit still, and had every reason to read that as a bug. They are two labelled groups now: the period figures under the selector that governs them, the all-time figures below and visually subordinate, being context rather than headline. Five and three fill their rows exactly, so the ragged edge goes with it. A test pins which tile belongs to which group by DOM containment, so moving a period metric into the all-time block fails rather than quietly restoring the confusion.
The dashboard had its own build target, its own login screen and its own token handling. That login asked for a wallet signature, which meant anyone wanting to look at a chart needed the partner's wallet key — their master secret, handed to whoever works in operations. It is a screen in the app now, reached from the burger menu, gated by a role the way the support dashboard is, and it reads the two endpoints with the ordinary session token. The separate entry point, the login screen, the partner auth hook, the white-label brand registry and the standalone build and start scripts are gone with it. Branding per partner goes too. Inside the DFX app behind the DFX login, a partner logo would claim something the page no longer is.
The move into the app dropped the language and theme switchers on the argument that the app provides them. It provides language only, and only on the settings page — a partner mid-analysis would have to leave the page to change it. Dark mode it does not provide at all, so the dashboard simply lost it, though its token system had never gone away; only the setter had. Both sit in the header again as one quiet group. Language drives the app's own language state rather than a second copy beside it, so the rest of the app follows along.
The header carried the program name twice: a small spaced-out line above the heading and the heading itself, word for word the same. A kicker earns its place by saying something the heading does not; this one only repeated it. The bold line stays and is now the page's only title, with a test that fails if a second heading with the same text appears.
The language switcher offers all four languages the app ships, but the partner namespace existed only in German — French and Italian users got the English source strings back. Both now carry the full set, matching the German key for key. Three values stay as they are: Blockchains and Date read the same in French, and Referral is left untranslated in Italian as it is in German.
"Non-Custodial Partner Program" wrapped onto two lines in the burger menu while every other entry sat on one. Menu and heading now have their own keys, so the list stays even at "NC Partner Program" and the page still announces itself in full.
The guard's decision was covered: inverting it failed six tests. Its wiring was not. Removing the guard call from the screen outright left all eighty-three green, and so did replacing the menu's role condition with a constant true — the whole access control of the page could be deleted without a red test. Two things caused that. The only test rendering the real screen mocked the guard hook to a no-op, and the menu test rendered a hand-written copy of the condition instead of the real navigation, which no test in the repository imported at all. Both are gone. The screen test lets the real guard run, the menu test renders the real navigation, and both mutations above now fail.
# Conflicts: # package.json # src/setupTests.ts
joshuakrueger-dfx
requested review from
TaprootFreak and
davidleomay
as code owners
August 4, 2026 09:26
A shared mock helper sat in src/__tests__/helpers/. Create React App treats every file under __tests__ as a suite, so a file with no tests in it failed the whole run — while the test count still read 863 of 863 passing, because a suite that never runs contributes no tests to count. It lives in src/test-helpers/ now, alongside no test matcher. Every other test file in this repository sits flat in __tests__ for exactly this reason.
The shared mock helper had to stay under __tests__, because tsconfig.build.json excludes that directory and the helper uses jest globals — moving it out put it in the production compile, where the namespace does not exist. But Create React App treats every file under __tests__ as a suite, so the helper failed the run while the test count still read 863 of 863 passing: a suite that never runs contributes no tests to count. Jest now ignores the helpers directory. `testMatch` would have been the tidier key and is listed as supported, but react-app-rewired drops it — asking Jest directly which files it collects showed the helper still in the list of ninety-two. `testPathIgnorePatterns` is the key this repository already uses, and it takes: ninety-one now.
The test asked only whether a value was a non-empty string. A key left standing as its English source text passed, and French and Italian were read by no test at all — the two files carrying fifty-five keys each could lose one without a red line anywhere. All three languages are now compared key for key against each other, and a value identical to its English source fails unless it is on a documented list: Blockchains and Date read the same in French, Blockchains and Swap in German, Referral in Italian. Each of those was checked against how the rest of the repository already treats the word.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Not symptom-driven: no incident triggered this. Wallet partners integrating DFX have no way to
see how their integration performs. DFXswiss/api#4587 adds the two endpoints that answer it; this is
the page that shows them.
Scale: Cake alone has 126,988 users in production and is the first of several wallet partners.
Smaller fix considered: hand partners the raw JSON and let them build their own view. Rejected —
every partner would rebuild the same charts, and the numbers only become useful once someone can
see a trend rather than a payload.
What
A screen at
/partner/dashboard, reached from the burger menu, gated by a role: volume andtransaction counts over time, breakdowns by asset, fiat currency, blockchain and payment method,
the referral position, and the all-time figures behind them.
Nothing partner-specific is coded anywhere. Any wallet gets its own numbers through the same page.
It lives in the app, not beside it
It began as a separate build target with its own entry point, its own login screen and its own token
handling. That login asked for a wallet signature — so anyone who wanted to look at a chart
needed the partner's wallet key, their master secret, in a browser.
It is a screen now. The role guard follows the pattern of the support dashboard
(
useSupportDashboardGuard→usePartnerDashboardGuard), the API calls go through the app's ownuseGuardedApiwith the ordinary session token, and the standalone entry point, login screen, authhook, brand registry and build scripts are gone. Per-partner branding went with them: inside the DFX
app behind the DFX login, a partner logo would claim something the page no longer is.
The frontend's role list is deliberately narrower than the API's. The endpoints accept
ClientCompany,KycClientCompany,NonCustodialWalletPartner,AdminandSuperAdmin; the pageadmits only
NonCustodialWalletPartner. An admin would see their own wallet, since there is no"show me partner X" parameter — a menu entry leading to an empty page or a 403 is worse than no
entry. This is a decision, not an oversight; please do not widen it without deciding the same
question again.
@dfx.swiss/coredoes not yet carryNonCustodialWalletPartner, so the guard compares the literalstring rather than an enum member. Once the package ships the value, this should switch to the enum
— a drift of one character silently hides the page from everyone entitled to it.
Every day is shown
The API used to withhold days below a k-anonymity threshold. It no longer does, and neither does the
page: a day with a single transaction is a low point on the curve, a day with none sits on the zero
line. There is no gap, and there cannot be one —
timelineSeriesreturns[number, number], so ahole is not representable and reintroducing one fails to compile.
Breakdown rows with no activity at all disappear rather than showing a permanent zero. The API
cannot emit such a row today (
GROUP BYonly yields values that occur), so the filter is asecond line rather than a fix — it stops the page trusting an invariant it does not own.
Verification
tsc -p tsconfig.build.jsonclean,lintclean under the repo's own--max-warnings 0.failed nothing: the only test rendering the real screen mocked the guard away.
the menu test rendered a hand-written copy of the condition, and no test in the repository
imported the real navigation at all.
tests fail.
not against the types: enum casing (
Day,Buy), the lowercase JSON keys (volume.buy),nullability, and the fields the sample does not cover are named rather than assumed. This is where
the previous version broke — it sent
granularity='day'and readdirection:'buy'.menu entry, and a day carrying a single transaction of 60 CHF rendered as a value.
For reviewers
NonCustodialWalletPartnerrole the guardadmits nobody and the endpoints reject the token.
--bg,--text,--primary,--surface— sit on.theme-light/.theme-dark, not on:root; what:rootcarries is namespaced primitives (--navy-*,--n-*,--fs-*) plus six shared ones(
--error,--info,--success,--warning,--font-sans,--font-mono). Nothing outside thedashboard defines any of them — the app has exactly one other stylesheet. Scoping them to the
dashboard element would break chart colours:
readThemeCssVarmeasures through a probe elementattached to the document root carrying only the theme class, which is how the charts get their
palette on a theme switch before the DOM class has caught up.
as its English source would pass. French and Italian carry the full key set, checked
programmatically against German, but no test reads those two files.