Skip to content
Merged
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
97 changes: 97 additions & 0 deletions src/features/investor/components/InvestorPortfolioTable.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import InvestorPortfolioTable from './InvestorPortfolioTable';
import type { PortfolioAsset } from '@/lib/aegis/types';

function makeAsset(overrides: Partial<PortfolioAsset> = {}): PortfolioAsset {
return {
id: 'ny-cre',
name: 'Manhattan Commercial Real Estate',
ticker: 'NY-CRE',
balance: 50.5,
decimals: 2,
metadata: {
assetClass: 'Real Estate',
issuer: 'Aegis Property Holdings LLC',
jurisdiction: 'United States',
description: 'Fractionalized ownership record.',
},
compliance: {
state: 'compliant',
label: 'Compliant',
detail: 'KYC current.',
},
transferEligibility: {
state: 'eligible',
reasons: [],
},
lifecycleStatus: {
current: 'active',
since: '2026-01-15T00:00:00Z',
history: [{ state: 'active', occurredAt: '2026-01-15T00:00:00Z' }],
},
isDataAvailable: true,
...overrides,
};
}

describe('InvestorPortfolioTable', () => {
it('renders asset cards when assets are available', () => {
render(
<InvestorPortfolioTable assets={[makeAsset()]} onTransferClick={vi.fn()} />,
);
expect(screen.getByText('Manhattan Commercial Real Estate')).toBeInTheDocument();
expect(screen.getByText('NY-CRE')).toBeInTheDocument();
});

it('shows empty state when no assets are available', () => {
render(
<InvestorPortfolioTable assets={[]} onTransferClick={vi.fn()} />,
);
expect(screen.getByText('No holdings yet')).toBeInTheDocument();
});

it('shows restricted compliance badge for restricted assets', () => {
const restricted = makeAsset({
id: 'restricted-1',
compliance: {
state: 'restricted',
label: 'Restricted',
detail: 'Accreditation required.',
},
transferEligibility: {
state: 'ineligible',
reasons: ['Restricted to accredited investors only.'],
},
});
render(
<InvestorPortfolioTable assets={[restricted]} onTransferClick={vi.fn()} />,
);
expect(screen.getByText('Restricted')).toBeInTheDocument();
expect(screen.getByText('Transfer restricted')).toBeInTheDocument();
});

it('shows unavailable section for assets with missing metadata', () => {
const unavailable = makeAsset({
id: 'unavail-1',
name: 'Unknown Asset',
ticker: 'UNK',
isDataAvailable: false,
});
render(
<InvestorPortfolioTable assets={[unavailable]} onTransferClick={vi.fn()} />,
);
expect(screen.getByText('Unavailable holdings (1)')).toBeInTheDocument();
expect(screen.getByText('Metadata temporarily unavailable')).toBeInTheDocument();
});

it('calls onTransferClick when transfer button is clicked', () => {
const onTransferClick = vi.fn();
render(
<InvestorPortfolioTable assets={[makeAsset()]} onTransferClick={onTransferClick} />,
);
screen.getByText('Transfer').click();
expect(onTransferClick).toHaveBeenCalledTimes(1);
});
});
70 changes: 70 additions & 0 deletions src/features/investor/components/InvestorPortfolioTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useMemo } from 'react';
import AssetCard from '@/features/assets/components/AssetCard';
import PortfolioEmptyState from './PortfolioEmptyState';
import type { PortfolioAsset } from '@/lib/aegis/types';

export interface InvestorPortfolioTableProps {
assets: PortfolioAsset[];
onTransferClick: (asset: PortfolioAsset) => void;
}

export default function InvestorPortfolioTable({
assets,
onTransferClick,
}: InvestorPortfolioTableProps) {
const unavailableAssets = useMemo(
() => assets.filter((asset) => !asset.isDataAvailable),
[assets],
);
const availableAssets = useMemo(
() => assets.filter((asset) => asset.isDataAvailable),
[assets],
);

return (
<div className="space-y-6">
{availableAssets.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{availableAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
onTransferClick={() => onTransferClick(asset)}
/>
))}
</div>
) : (
<PortfolioEmptyState />
)}

{unavailableAssets.length > 0 && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4">
<h3 className="text-sm font-semibold text-amber-800 mb-2">
Unavailable holdings ({unavailableAssets.length})
</h3>
<p className="text-xs text-amber-700 mb-3">
These assets appear on-chain but metadata/compliance records could not be
resolved. Balances are shown for information only.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{unavailableAssets.map((asset) => (
<div
key={asset.id}
className="rounded-lg border border-amber-100 bg-white p-4 text-sm text-slate-700"
>
<div className="font-medium">{asset.name}</div>
<div className="text-xs text-slate-500">{asset.ticker}</div>
<div className="mt-2 text-xs text-amber-700">
{asset.balance.toLocaleString('en-US')} {asset.ticker}
</div>
<div className="mt-1 text-xs text-amber-700">
Metadata temporarily unavailable
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
24 changes: 2 additions & 22 deletions src/features/investor/components/PortfolioList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { usePortfolio } from '../hooks/usePortfolio';
import { EmptyState } from '@/components/states';
import PortfolioDisclaimer from './PortfolioDisclaimer';
import TransferModal from './TransferModal';
import InvestorPortfolioTable from './InvestorPortfolioTable';

const SKELETON_COUNT = 3;

Expand Down Expand Up @@ -60,31 +61,10 @@ export default function PortfolioList() {
);
}

if (assets.length === 0) {
return (
<EmptyState
icon={undefined}
title="No holdings yet"
description="This address does not currently hold any Aegis RWA tokens. Once an issuer mints assets to your wallet, they will appear here."
variant="no-data"
docsLink={{
label: 'Learn about RWA tokens',
href: '/docs/rwa-tokens',
}}
/>
);
}

return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{assets.map((asset) => (
<AssetCard key={asset.id} asset={asset} onTransferClick={() => setActiveAsset(asset)} />
))}
</div>

<InvestorPortfolioTable assets={assets} onTransferClick={setActiveAsset} />
<PortfolioDisclaimer />

{activeAsset && <TransferModal asset={activeAsset} onClose={() => setActiveAsset(null)} />}
</div>
);
Expand Down
Loading