Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion frontend/__tests__/RegistryPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function makeServices(count: number) {
provider: `G${'A'.repeat(55)}`,
reputation: 100,
active: true,

registered_at: 123456,
}));
}

Expand Down
82 changes: 82 additions & 0 deletions frontend/__tests__/StellarAddress.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import StellarAddress from '../components/StellarAddress';

describe('StellarAddress Component', () => {
const mockAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFL';

beforeEach(() => {
jest.clearAllMocks();
});

it('renders truncated address correctly', () => {
render(<StellarAddress address={mockAddress} />);

expect(screen.getByText('GAAAAA...AWFL')).toBeInTheDocument();
});

it('provides the correct explorer link', () => {
render(<StellarAddress address={mockAddress} />);

const link = screen.getByRole('link', { name: /view account/i });
expect(link).toHaveAttribute('href', `https://stellar.expert/explorer/testnet/account/${mockAddress}`);
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});

it('provides the full address for screen readers and tooltips', () => {
render(<StellarAddress address={mockAddress} />);

const srOnlySpan = screen.getByText(`Stellar address: ${mockAddress}`);
expect(srOnlySpan).toBeInTheDocument();
expect(srOnlySpan).toHaveClass('sr-only');

expect(screen.getByText(mockAddress)).toBeInTheDocument();
});

it('supports custom classNames', () => {
const { container } = render(<StellarAddress address={mockAddress} className="test-custom-class" />);

expect(container.firstChild).toHaveClass('test-custom-class');
});

it('handles clipboard copy with visual confirmation and timer reset', async () => {
jest.useFakeTimers();

const writeTextMock = jest.fn().mockResolvedValue(undefined);
Object.assign(navigator, {
clipboard: {
writeText: writeTextMock,
},
});

render(<StellarAddress address={mockAddress} />);

const copyButton = screen.getByRole('button', { name: /copy stellar address/i });
expect(screen.queryByText('Copied')).not.toBeInTheDocument();

fireEvent.click(copyButton);

expect(writeTextMock).toHaveBeenCalledWith(mockAddress);

// Flush clipboard writeText microtasks
await act(async () => {
await Promise.resolve();
});

expect(screen.getByText('Copied')).toBeInTheDocument();

act(() => {
jest.advanceTimersByTime(1500);
});

expect(screen.queryByText('Copied')).not.toBeInTheDocument();

jest.useRealTimers();
});

it('returns null if no address is provided', () => {
const { container } = render(<StellarAddress address="" />);
expect(container.firstChild).toBeNull();
});
});
30 changes: 4 additions & 26 deletions frontend/app/agents/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import { fetchAgentEligibility } from '@/lib/contract';
import { useWallet } from '@/components/WalletContext';
import { kitSignTransaction } from '@/lib/wallet';
import { ScoreHistoryChart } from '@/components/ScoreHistoryChart';
import StellarAddress from '@/components/StellarAddress';

const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3001';
const EXPLORER_URL =
process.env.NEXT_PUBLIC_EXPLORER_URL ?? 'https://stellar.expert/explorer/testnet';

const ACCESS_TIERS = [
{ label: 'Basic services', minScore: 0 },
Expand Down Expand Up @@ -62,7 +61,6 @@ export default function AgentProfilePage() {
const [policy, setPolicy] = useState<SpendingPolicy | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);

const [customMinScore, setCustomMinScore] = useState(0);
const [isEligible, setIsEligible] = useState<boolean | null>(null);
Expand Down Expand Up @@ -90,13 +88,6 @@ export default function AgentProfilePage() {
load();
}, [load]);

function copyAddress() {
if (!agent) return;
navigator.clipboard.writeText(agent.address);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}

const checkEligibility = async () => {
if (!address) return;
setCheckingEligibility(true);
Expand Down Expand Up @@ -181,20 +172,7 @@ export default function AgentProfilePage() {
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-semibold tracking-tight mb-2">{agent.name}</h1>
<div className="flex items-center gap-2 flex-wrap">
<a
href={`${EXPLORER_URL}/account/${agent.address}`}
target="_blank"
rel="noopener noreferrer"
className="mono text-sm text-secondary hover:text-primary transition-colors truncate"
>
{agent.address}
</a>
<button
onClick={copyAddress}
className="text-xs text-secondary hover:text-primary transition-colors shrink-0"
>
{copied ? 'Copied' : 'Copy'}
</button>
<StellarAddress address={agent.address} className="text-sm" />
</div>
</div>
<div className="flex items-center gap-6">
Expand Down Expand Up @@ -356,7 +334,7 @@ export default function AgentProfilePage() {
<MetaItem label="Total volume" value={`$${totalVolumeUsdc} USDC`} />
<MetaItem label="Registered at ledger" value={`#${Number(agent.registered_at).toLocaleString()}`} />
<MetaItem label="Last active at ledger" value={`#${Number(agent.last_active).toLocaleString()}`} />
<MetaItem label="Owner" value={`${agent.owner.slice(0, 6)}…${agent.owner.slice(-4)}`} mono />
<MetaItem label="Owner" value={<StellarAddress address={agent.owner} className="text-sm" />} />
</div>
</div>
);
Expand Down Expand Up @@ -385,7 +363,7 @@ function StatCard({
);
}

function MetaItem({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
function MetaItem({ label, value, mono }: { label: string; value: React.ReactNode; mono?: boolean }) {
return (
<div>
<div className="text-xs text-secondary mb-0.5">{label}</div>
Expand Down
17 changes: 2 additions & 15 deletions frontend/components/AgentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import Link from 'next/link';
import type { AgentEntry } from '@/lib/types';
import ScoreBadge from './ScoreBadge';

function truncateAddr(addr: string) {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}

const EXPLORER_URL =
process.env.NEXT_PUBLIC_EXPLORER_URL ?? 'https://stellar.expert/explorer/testnet';
import StellarAddress from './StellarAddress';

interface Props {
agent: AgentEntry;
Expand All @@ -31,14 +25,7 @@ export default function AgentCard({ agent }: Props) {
>
{agent.name}
</Link>
<a
href={`${EXPLORER_URL}/account/${agent.address}`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-secondary hover:text-primary transition-colors"
>
{truncateAddr(agent.address)}
</a>
<StellarAddress address={agent.address} className="text-xs" />
</div>
<ScoreBadge score={agent.score} />
</div>
Expand Down
17 changes: 2 additions & 15 deletions frontend/components/ServiceCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,7 @@ import { useState } from 'react';
import { getCategoryMeta } from '@/lib/categoryMeta';
import type { ServiceEntry } from '@/lib/types';
import { submitReputation } from '@/lib/contract';

const EXPLORER_URL =
process.env.NEXT_PUBLIC_EXPLORER_URL ?? 'https://stellar.expert/explorer/testnet';

function truncateAddr(addr: string) {
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
}
import StellarAddress from './StellarAddress';

function truncateEndpoint(url: string) {
try {
Expand Down Expand Up @@ -112,14 +106,7 @@ export default function ServiceCard({ service, onReputationChange }: Props) {

{/* Footer */}
<div className="flex items-center justify-between border-t border-border pt-3 mt-1">
<a
href={`${EXPLORER_URL}/account/${service.provider}`}
target="_blank"
rel="noopener noreferrer"
className="mono text-xs text-secondary hover:text-primary transition-colors"
>
{truncateAddr(service.provider)}
</a>
<StellarAddress address={service.provider} className="text-xs" />
<span className="text-xs text-secondary mono">{ledger}</span>
</div>

Expand Down
88 changes: 88 additions & 0 deletions frontend/components/StellarAddress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use client';

import { useState } from 'react';

interface Props {
address: string;
className?: string;
}

const EXPLORER_URL =
process.env.NEXT_PUBLIC_EXPLORER_URL ?? 'https://stellar.expert/explorer/testnet';

export default function StellarAddress({ address, className = '' }: Props) {
const [copied, setCopied] = useState(false);

const copyToClipboard = async () => {
if (!address) return;
try {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
Comment thread
Godfrey-Delight marked this conversation as resolved.
} catch (err) {
console.error('Failed to copy address:', err);
}
};

if (!address) return null;

const truncated = address.length >= 10
? `${address.slice(0, 6)}...${address.slice(-4)}`
: address;

return (
<div className={`inline-flex items-center gap-1.5 mono ${className}`}>
{/* Screen reader only full address description */}
<span className="sr-only">Stellar address: {address}</span>

{/* Address Link & Tooltip wrapper */}
<div className="relative group">
<a
href={`${EXPLORER_URL}/account/${address}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary hover:text-primary transition-colors hover:underline"
aria-label={`View account ${address} on Stellar Expert`}
>
{truncated}
</a>

{/* Custom Premium Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block bg-primary text-white text-xs px-2.5 py-1.5 rounded-lg whitespace-nowrap z-50 shadow-lg pointer-events-none transition-opacity duration-200">
<span className="font-mono text-[11px] select-all">{address}</span>
Comment thread
Godfrey-Delight marked this conversation as resolved.
{/* Arrow indicator */}
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-primary" />
</div>
</div>

{/* Copy Action Button */}
<button
onClick={copyToClipboard}
className="p-1 rounded text-secondary hover:text-primary hover:bg-border/30 transition-all focus:outline-none focus:ring-1 focus:ring-primary/20 shrink-0"
title="Copy full address"
aria-label={copied ? "Address copied" : "Copy Stellar address"}
>
{copied ? (
<span className="text-[10px] font-medium text-success bg-success/10 px-1.5 py-0.5 rounded leading-none">
Copied
</span>
) : (
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"
/>
</svg>
)}
</button>
</div>
);
}
21 changes: 21 additions & 0 deletions frontend/lib/sort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ function makeService(overrides: Partial<ServiceEntry> = {}): ServiceEntry {
};
}

function makeAgent(overrides: Partial<AgentEntry> = {}): AgentEntry {
return {
address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFL',
name: 'Test Agent',
description: 'Test description',
owner: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWFL',
score: 0,
total_payments: '0',
successful_payments: '0',
failed_payments: '0',
total_volume_stroops: '0',
registered_at: '100',
last_active: '100',
active: true,
flagged: false,
flag_reason: '',
...overrides,
};
}




describe('sortServices', () => {
Expand Down
Loading