From ab3df71f15adac0a88d53ba1e835deb920c6e323 Mon Sep 17 00:00:00 2001
From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
Date: Fri, 29 May 2026 12:27:40 +0200
Subject: [PATCH] feat(partner): add /partner dashboard for partner
self-service
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the frontend counterpart to DFXswiss/api#3787. Referral partners
(role=Partner) can now look up their referees, assign or remove
individual onboarding fees, and review their full referee list directly
on app.dfx.swiss — replacing the manual Google-Sheet workflow currently
operated by the Compliance team.
- usePartnerGuard (Admin + Partner)
- usePartner hook with 5 methods against /v1/partner/* endpoints
- Local PartnerUserInfo / PartnerFee DTOs (admin-style, mirrors
compliance.hook pattern; can later migrate to @dfx.swiss/react once
packages 1.4.0-beta.2 is consumed)
- Routes:
/partner → hub (set-fee tile + referees tile)
/partner/onboarding → lookup-by-address form + fee dropdown
+ Set / Remove buttons, server enforces scope
/partner/history → table of all users with usedRef == caller.ref
- PartnerUserCard component for the lookup result
Backend scope enforcement (api#3787) guarantees the partner can only
touch users whose usedRef is either empty (defaultRef '000-000') or
equal to the caller's own ref; UI surfaces this via the canModify flag.
---
src/App.tsx | 11 +
src/components/partner/partner-user-card.tsx | 38 ++++
src/dto/partner.dto.ts | 24 +++
src/hooks/guard.hook.ts | 6 +
src/hooks/partner.hook.ts | 50 +++++
src/screens/partner-history.screen.tsx | 72 +++++++
src/screens/partner-onboarding.screen.tsx | 209 +++++++++++++++++++
src/screens/partner.screen.tsx | 33 +++
8 files changed, 443 insertions(+)
create mode 100644 src/components/partner/partner-user-card.tsx
create mode 100644 src/dto/partner.dto.ts
create mode 100644 src/hooks/partner.hook.ts
create mode 100644 src/screens/partner-history.screen.tsx
create mode 100644 src/screens/partner-onboarding.screen.tsx
create mode 100644 src/screens/partner.screen.tsx
diff --git a/src/App.tsx b/src/App.tsx
index d95ce5ee..14c16c1c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -88,6 +88,9 @@ const RealunitTransactionDetailScreen = lazy(() => import('./screens/realunit-tr
const RealunitUserScreen = lazy(() => import('./screens/realunit-user.screen'));
const PersonalIbanScreen = lazy(() => import('./screens/personal-iban.screen'));
const BuyCryptoUpdateScreen = lazy(() => import('./screens/buy-crypto-update.screen'));
+const PartnerScreen = lazy(() => import('./screens/partner.screen'));
+const PartnerOnboardingScreen = lazy(() => import('./screens/partner-onboarding.screen'));
+const PartnerHistoryScreen = lazy(() => import('./screens/partner-history.screen'));
const DashboardScreen = lazy(() => import('./screens/dashboard.screen'));
const DashboardFinancialScreen = lazy(() => import('./screens/dashboard-financial.screen'));
const DashboardFinancialOverviewScreen = lazy(() => import('./screens/dashboard-financial-overview.screen'));
@@ -508,6 +511,14 @@ export const Routes = [
},
],
},
+ {
+ path: 'partner',
+ children: [
+ { index: true, element: withSuspense() },
+ { path: 'onboarding', element: withSuspense() },
+ { path: 'history', element: withSuspense() },
+ ],
+ },
{
path: 'dashboard',
children: [
diff --git a/src/components/partner/partner-user-card.tsx b/src/components/partner/partner-user-card.tsx
new file mode 100644
index 00000000..50b01e37
--- /dev/null
+++ b/src/components/partner/partner-user-card.tsx
@@ -0,0 +1,38 @@
+import { PartnerUserInfo } from 'src/dto/partner.dto';
+
+interface PartnerUserCardProps {
+ user: PartnerUserInfo;
+}
+
+export function PartnerUserCard({ user }: PartnerUserCardProps): JSX.Element {
+ const fullName = [user.firstname, user.surname].filter(Boolean).join(' ') || '—';
+
+ return (
+
+
+ UserData ID
+ {user.id}
+
+
+ Name
+ {fullName}
+
+
+ Email
+ {user.mail || '—'}
+
+
+ Status
+ {user.status}
+
+
+ usedRef
+ {user.usedRef}
+
+
+ Current fees
+ {user.feeIds.length ? user.feeIds.join(', ') : '—'}
+
+
+ );
+}
diff --git a/src/dto/partner.dto.ts b/src/dto/partner.dto.ts
new file mode 100644
index 00000000..14fe8afa
--- /dev/null
+++ b/src/dto/partner.dto.ts
@@ -0,0 +1,24 @@
+import { KycStatus } from '@dfx.swiss/react';
+
+export interface PartnerUserInfo {
+ id: number;
+ status: string;
+ mail?: string;
+ firstname?: string;
+ surname?: string;
+ usedRef: string;
+ feeIds: number[];
+ canModify: boolean;
+}
+
+export interface PartnerFee {
+ id: number;
+ label: string;
+ type: string;
+ rate: number;
+ fixed: number;
+}
+
+export interface PartnerRefereeDisplay extends PartnerUserInfo {
+ kycStatus?: KycStatus;
+}
diff --git a/src/hooks/guard.hook.ts b/src/hooks/guard.hook.ts
index 43496bcb..c1fe062f 100644
--- a/src/hooks/guard.hook.ts
+++ b/src/hooks/guard.hook.ts
@@ -23,6 +23,12 @@ export function useComplianceGuard(redirectPath = '/', isActive = true) {
useUserRoleGuard([UserRole.ADMIN, UserRole.COMPLIANCE], redirectPath, isActive);
}
+// 'Partner' is exported as UserRole.PARTNER in @dfx.swiss/react >= 1.4.0-beta.2.
+// Cast keeps this PR independent of the packages publish; switch to UserRole.PARTNER once consumed.
+export function usePartnerGuard(redirectPath = '/', isActive = true) {
+ useUserRoleGuard([UserRole.ADMIN, 'Partner' as UserRole], redirectPath, isActive);
+}
+
export const SUPPORT_DASHBOARD_ROLES = [UserRole.ADMIN, UserRole.COMPLIANCE, UserRole.SUPPORT, UserRole.MARKETING];
export function useSupportDashboardGuard(redirectPath = '/', isActive = true) {
diff --git a/src/hooks/partner.hook.ts b/src/hooks/partner.hook.ts
new file mode 100644
index 00000000..5ecf7c80
--- /dev/null
+++ b/src/hooks/partner.hook.ts
@@ -0,0 +1,50 @@
+import { useApi } from '@dfx.swiss/react';
+import { useMemo } from 'react';
+import { PartnerFee, PartnerUserInfo } from 'src/dto/partner.dto';
+
+export interface UsePartner {
+ findUserByAddress: (address: string) => Promise;
+ getMyReferees: () => Promise;
+ getAvailableFees: () => Promise;
+ setOnboarding: (userDataId: number, feeId: number) => Promise;
+ removeFee: (userDataId: number, feeId: number) => Promise;
+}
+
+export function usePartner(): UsePartner {
+ const { call } = useApi();
+
+ async function findUserByAddress(address: string): Promise {
+ return call({
+ url: `partner/user?address=${encodeURIComponent(address)}`,
+ method: 'GET',
+ });
+ }
+
+ async function getMyReferees(): Promise {
+ return call({ url: 'partner/users', method: 'GET' });
+ }
+
+ async function getAvailableFees(): Promise {
+ return call({ url: 'partner/fees', method: 'GET' });
+ }
+
+ async function setOnboarding(userDataId: number, feeId: number): Promise {
+ return call({
+ url: `partner/user/${userDataId}/onboarding`,
+ method: 'PUT',
+ data: { feeId },
+ });
+ }
+
+ async function removeFee(userDataId: number, feeId: number): Promise {
+ return call({
+ url: `partner/user/${userDataId}/fee?fee=${feeId}`,
+ method: 'DELETE',
+ });
+ }
+
+ return useMemo(
+ () => ({ findUserByAddress, getMyReferees, getAvailableFees, setOnboarding, removeFee }),
+ [call],
+ );
+}
diff --git a/src/screens/partner-history.screen.tsx b/src/screens/partner-history.screen.tsx
new file mode 100644
index 00000000..601bb18f
--- /dev/null
+++ b/src/screens/partner-history.screen.tsx
@@ -0,0 +1,72 @@
+import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components';
+import { useCallback, useEffect, useState } from 'react';
+import { ErrorHint } from 'src/components/error-hint';
+import { PartnerUserInfo } from 'src/dto/partner.dto';
+import { usePartnerGuard } from 'src/hooks/guard.hook';
+import { useLayoutOptions } from 'src/hooks/layout-config.hook';
+import { useNavigation } from 'src/hooks/navigation.hook';
+import { usePartner } from 'src/hooks/partner.hook';
+
+export default function PartnerHistoryScreen(): JSX.Element {
+ usePartnerGuard();
+
+ const { navigate } = useNavigation();
+ const { getMyReferees } = usePartner();
+
+ const [referees, setReferees] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState();
+
+ const onBack = useCallback(() => navigate('/partner'), [navigate]);
+ useLayoutOptions({ title: 'My Referees', backButton: true, onBack });
+
+ useEffect(() => {
+ getMyReferees()
+ .then(setReferees)
+ .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load referees'))
+ .finally(() => setLoading(false));
+ }, [getMyReferees]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error) return ;
+
+ if (!referees.length) {
+ return No referees yet.
;
+ }
+
+ return (
+
+
+
+
+
+ | ID |
+ Name |
+ Email |
+ Status |
+ Fees |
+
+
+
+ {referees.map((r) => (
+
+ | {r.id} |
+ {[r.firstname, r.surname].filter(Boolean).join(' ') || '—'} |
+ {r.mail || '—'} |
+ {r.status} |
+ {r.feeIds.length ? r.feeIds.join(', ') : '—'} |
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/screens/partner-onboarding.screen.tsx b/src/screens/partner-onboarding.screen.tsx
new file mode 100644
index 00000000..3c9753b7
--- /dev/null
+++ b/src/screens/partner-onboarding.screen.tsx
@@ -0,0 +1,209 @@
+import { Utils, Validations } from '@dfx.swiss/react';
+import {
+ Form,
+ StyledButton,
+ StyledButtonColor,
+ StyledButtonWidth,
+ StyledDropdown,
+ StyledInput,
+ StyledVerticalStack,
+} from '@dfx.swiss/react-components';
+import { useCallback, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { ErrorHint } from 'src/components/error-hint';
+import { PartnerUserCard } from 'src/components/partner/partner-user-card';
+import { useLayoutContext } from 'src/contexts/layout.context';
+import { useSettingsContext } from 'src/contexts/settings.context';
+import { PartnerFee, PartnerUserInfo } from 'src/dto/partner.dto';
+import { usePartnerGuard } from 'src/hooks/guard.hook';
+import { useLayoutOptions } from 'src/hooks/layout-config.hook';
+import { useNavigation } from 'src/hooks/navigation.hook';
+import { usePartner } from 'src/hooks/partner.hook';
+
+interface FormData {
+ address: string;
+ feeId: string;
+}
+
+export default function PartnerOnboardingScreen(): JSX.Element {
+ usePartnerGuard();
+
+ const { translate, translateError } = useSettingsContext();
+ const { rootRef } = useLayoutContext();
+ const { navigate } = useNavigation();
+ const { findUserByAddress, getAvailableFees, setOnboarding, removeFee } = usePartner();
+
+ const [lookupLoading, setLookupLoading] = useState(false);
+ const [submitLoading, setSubmitLoading] = useState(false);
+ const [fees, setFees] = useState();
+ const [feesError, setFeesError] = useState();
+ const [target, setTarget] = useState();
+ const [error, setError] = useState();
+ const [success, setSuccess] = useState();
+
+ const onBack = useCallback(() => navigate('/partner'), [navigate]);
+ useLayoutOptions({ title: 'Set Onboarding Fee', backButton: true, onBack });
+
+ const {
+ control,
+ handleSubmit,
+ watch,
+ reset,
+ formState: { errors, isValid },
+ } = useForm({ mode: 'onChange', defaultValues: { address: '', feeId: '' } });
+
+ const selectedFeeId = watch('feeId');
+
+ const ensureFeesLoaded = useCallback(async () => {
+ if (fees) return;
+ try {
+ const list = await getAvailableFees();
+ setFees(list);
+ } catch (e) {
+ setFeesError(e instanceof Error ? e.message : 'Failed to load fees');
+ }
+ }, [fees, getAvailableFees]);
+
+ const onLookup = useCallback(
+ async (data: FormData) => {
+ setLookupLoading(true);
+ setError(undefined);
+ setSuccess(undefined);
+ setTarget(undefined);
+ try {
+ const user = await findUserByAddress(data.address);
+ setTarget(user);
+ await ensureFeesLoaded();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Lookup failed');
+ } finally {
+ setLookupLoading(false);
+ }
+ },
+ [findUserByAddress, ensureFeesLoaded],
+ );
+
+ const onSetFee = useCallback(async () => {
+ if (!target || !selectedFeeId) return;
+ setSubmitLoading(true);
+ setError(undefined);
+ setSuccess(undefined);
+ try {
+ await setOnboarding(target.id, +selectedFeeId);
+ setSuccess('Fee assigned, user marked Active, ref updated.');
+ const refreshed = await findUserByAddress(watch('address'));
+ setTarget(refreshed);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Operation failed');
+ } finally {
+ setSubmitLoading(false);
+ }
+ }, [target, selectedFeeId, setOnboarding, findUserByAddress, watch]);
+
+ const onRemoveFee = useCallback(async () => {
+ if (!target || !selectedFeeId) return;
+ setSubmitLoading(true);
+ setError(undefined);
+ setSuccess(undefined);
+ try {
+ await removeFee(target.id, +selectedFeeId);
+ setSuccess('Fee removed.');
+ const refreshed = await findUserByAddress(watch('address'));
+ setTarget(refreshed);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Operation failed');
+ } finally {
+ setSubmitLoading(false);
+ }
+ }, [target, selectedFeeId, removeFee, findUserByAddress, watch]);
+
+ const onReset = useCallback(() => {
+ reset();
+ setTarget(undefined);
+ setError(undefined);
+ setSuccess(undefined);
+ }, [reset]);
+
+ const rules = Utils.createRules({
+ address: Validations.Required,
+ });
+
+ const selectedFee = fees?.find((f) => f.id === +selectedFeeId);
+ const isFeeAssigned = !!target && !!selectedFee && target.feeIds.includes(selectedFee.id);
+
+ return (
+
+ );
+}
diff --git a/src/screens/partner.screen.tsx b/src/screens/partner.screen.tsx
new file mode 100644
index 00000000..f69a0a13
--- /dev/null
+++ b/src/screens/partner.screen.tsx
@@ -0,0 +1,33 @@
+import { usePartnerGuard } from 'src/hooks/guard.hook';
+import { useLayoutOptions } from 'src/hooks/layout-config.hook';
+import { useNavigation } from 'src/hooks/navigation.hook';
+
+export default function PartnerScreen(): JSX.Element {
+ usePartnerGuard();
+ useLayoutOptions({ title: 'Partner' });
+
+ const { navigate } = useNavigation();
+
+ return (
+
+
navigate('/partner/onboarding')}
+ >
+
Set Onboarding Fee
+
+ Look up a referee by address, assign or remove their individual fee
+
+
+
navigate('/partner/history')}
+ >
+
My Referees
+
+ List of all users currently linked to your referral code
+
+
+
+ );
+}