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 ( +
+
+ + + + + + + + + + + + {referees.map((r) => ( + + + + + + + + ))} + +
IDNameEmailStatusFees
{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 ( +
+ + + + + + {error && } + {feesError && } + + {target && ( + <> + + + {target.canModify ? ( + <> + + name="feeId" + rootRef={rootRef} + label={translate('screens/payment', 'Fee')} + placeholder={translate('general/actions', 'Select') + '...'} + items={fees ?? []} + labelFunc={(f) => f.label} + descriptionFunc={(f) => `${f.fixed} CHF fixed · ${(f.rate * 100).toFixed(2)}%`} + full + /> + +
+ + +
+ + ) : ( +

+ This user is referred by another partner — read-only. +

+ )} + + {success &&

{success}

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