Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down Expand Up @@ -508,6 +511,14 @@ export const Routes = [
},
],
},
{
path: 'partner',
children: [
{ index: true, element: withSuspense(<PartnerScreen />) },
{ path: 'onboarding', element: withSuspense(<PartnerOnboardingScreen />) },
{ path: 'history', element: withSuspense(<PartnerHistoryScreen />) },
],
},
{
path: 'dashboard',
children: [
Expand Down
38 changes: 38 additions & 0 deletions src/components/partner/partner-user-card.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-white rounded-lg shadow p-4 text-sm space-y-1" style={{ color: '#111827' }}>
<div className="flex justify-between">
<span className="text-dfxGray-700">UserData ID</span>
<span className="font-mono">{user.id}</span>
</div>
<div className="flex justify-between">
<span className="text-dfxGray-700">Name</span>
<span>{fullName}</span>
</div>
<div className="flex justify-between">
<span className="text-dfxGray-700">Email</span>
<span>{user.mail || '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-dfxGray-700">Status</span>
<span>{user.status}</span>
</div>
<div className="flex justify-between">
<span className="text-dfxGray-700">usedRef</span>
<span className="font-mono">{user.usedRef}</span>
</div>
<div className="flex justify-between">
<span className="text-dfxGray-700">Current fees</span>
<span className="font-mono">{user.feeIds.length ? user.feeIds.join(', ') : '—'}</span>
</div>
</div>
);
}
24 changes: 24 additions & 0 deletions src/dto/partner.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions src/hooks/guard.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions src/hooks/partner.hook.ts
Original file line number Diff line number Diff line change
@@ -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<PartnerUserInfo>;
getMyReferees: () => Promise<PartnerUserInfo[]>;
getAvailableFees: () => Promise<PartnerFee[]>;
setOnboarding: (userDataId: number, feeId: number) => Promise<void>;
removeFee: (userDataId: number, feeId: number) => Promise<void>;
}

export function usePartner(): UsePartner {
const { call } = useApi();

async function findUserByAddress(address: string): Promise<PartnerUserInfo> {
return call<PartnerUserInfo>({
url: `partner/user?address=${encodeURIComponent(address)}`,
method: 'GET',
});
}

async function getMyReferees(): Promise<PartnerUserInfo[]> {
return call<PartnerUserInfo[]>({ url: 'partner/users', method: 'GET' });
}

async function getAvailableFees(): Promise<PartnerFee[]> {
return call<PartnerFee[]>({ url: 'partner/fees', method: 'GET' });
}

async function setOnboarding(userDataId: number, feeId: number): Promise<void> {
return call<void>({
url: `partner/user/${userDataId}/onboarding`,
method: 'PUT',
data: { feeId },
});
}

async function removeFee(userDataId: number, feeId: number): Promise<void> {
return call<void>({
url: `partner/user/${userDataId}/fee?fee=${feeId}`,
method: 'DELETE',
});
}

return useMemo(
() => ({ findUserByAddress, getMyReferees, getAvailableFees, setOnboarding, removeFee }),
[call],
);
}
72 changes: 72 additions & 0 deletions src/screens/partner-history.screen.tsx
Original file line number Diff line number Diff line change
@@ -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<PartnerUserInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>();

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 (
<div className="p-4 flex justify-center">
<StyledLoadingSpinner size={SpinnerSize.LG} />
</div>
);
}

if (error) return <ErrorHint message={error} />;

if (!referees.length) {
return <p className="p-4 text-center text-dfxGray-700">No referees yet.</p>;
}

return (
<div className="p-4 w-full" style={{ color: '#111827' }}>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-dfxGray-300 text-left">
<th className="py-2 pr-3">ID</th>
<th className="py-2 pr-3">Name</th>
<th className="py-2 pr-3">Email</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Fees</th>
</tr>
</thead>
<tbody>
{referees.map((r) => (
<tr key={r.id} className="border-b border-dfxGray-300">
<td className="py-2 pr-3 font-mono">{r.id}</td>
<td className="py-2 pr-3">{[r.firstname, r.surname].filter(Boolean).join(' ') || '—'}</td>
<td className="py-2 pr-3">{r.mail || '—'}</td>
<td className="py-2 pr-3">{r.status}</td>
<td className="py-2 pr-3 font-mono">{r.feeIds.length ? r.feeIds.join(', ') : '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
Loading
Loading