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
+
+
+
+ );
+}