diff --git a/invofi/apps/frontend/src/app/portfolio/page.tsx b/invofi/apps/frontend/src/app/portfolio/page.tsx index 0e41c59c3..41e322b76 100644 --- a/invofi/apps/frontend/src/app/portfolio/page.tsx +++ b/invofi/apps/frontend/src/app/portfolio/page.tsx @@ -19,6 +19,7 @@ import { stroopsToUsd } from '@/lib/live/prices'; import { useLivePortfolio } from '@/components/portfolio/LivePortfolioProvider'; import { ConnectionStatus } from '@/components/portfolio/ConnectionStatus'; import { RepaymentProgress } from '@/components/portfolio/RepaymentProgress'; +import { ExplorerLink } from '@/components/common/ExplorerLink'; import type { LivePosition } from '@/lib/live/types'; @@ -296,14 +297,13 @@ function PositionCard({ offer }: { offer: LivePosition }) {
- ↗ - +

{interestRateLabel(offer.interest_rate)} · {durationLabel(offer.duration)} diff --git a/invofi/apps/frontend/src/components/common/ExplorerLink.test.tsx b/invofi/apps/frontend/src/components/common/ExplorerLink.test.tsx new file mode 100644 index 000000000..1a82b81a7 --- /dev/null +++ b/invofi/apps/frontend/src/components/common/ExplorerLink.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ExplorerLink } from './ExplorerLink'; + +describe('ExplorerLink', () => { + it('renders a contract link with the correct href', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute( + 'href', + 'https://stellar.expert/explorer/testnet/contract/abc123', + ); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noreferrer noopener'); + }); + + it('renders an account link', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute( + 'href', + 'https://stellar.expert/explorer/testnet/account/GABCDEF123456', + ); + }); + + it('renders a transaction link', () => { + render(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute( + 'href', + 'https://stellar.expert/explorer/testnet/tx/txhash', + ); + }); + + it('renders children instead of the raw value', () => { + render(View); + expect(screen.getByRole('link')).toHaveTextContent('View'); + }); + + it('applies a custom className', () => { + render(); + expect(screen.getByRole('link')).toHaveClass('font-mono'); + }); + + it('defaults title to "View on Stellar Expert"', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('title', 'View on Stellar Expert'); + }); + + it('accepts a custom title', () => { + render(); + expect(screen.getByRole('link')).toHaveAttribute('title', 'Check it out'); + }); +}); \ No newline at end of file diff --git a/invofi/apps/frontend/src/components/common/ExplorerLink.tsx b/invofi/apps/frontend/src/components/common/ExplorerLink.tsx new file mode 100644 index 000000000..67d168c92 --- /dev/null +++ b/invofi/apps/frontend/src/components/common/ExplorerLink.tsx @@ -0,0 +1,41 @@ +import { explorerContractUrl, explorerAccountUrl, explorerTxUrl } from '@/lib/constants'; + +interface ExplorerLinkProps { + type: 'contract' | 'account' | 'tx'; + value: string; + children?: React.ReactNode; + className?: string; + title?: string; +} + +/** + * Reusable Stellar Expert explorer link. + * Uses the network-aware helpers from `@/lib/constants` so testnet vs mainnet + * is handled in one place — no inline `STELLAR_EXPERT` constants in call sites. + */ +export function ExplorerLink({ + type, + value, + children, + className = '', + title, +}: ExplorerLinkProps) { + const url = + type === 'contract' + ? explorerContractUrl(value) + : type === 'account' + ? explorerAccountUrl(value) + : explorerTxUrl(value); + + return ( + + {children ?? value} + + ); +} \ No newline at end of file diff --git a/invofi/apps/frontend/src/components/layout/Footer.tsx b/invofi/apps/frontend/src/components/layout/Footer.tsx index 572bf60a0..f6092a9bc 100644 --- a/invofi/apps/frontend/src/components/layout/Footer.tsx +++ b/invofi/apps/frontend/src/components/layout/Footer.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { useTranslations } from 'next-intl'; -import { REGISTRY_CONTRACT_ID, STELLAR_NETWORK, explorerContractUrl } from '@/lib/constants'; +import { ExplorerLink } from '@/components/common/ExplorerLink'; +import { REGISTRY_CONTRACT_ID, STELLAR_NETWORK } from '@/lib/constants'; import { Heart } from 'lucide-react'; const SITE_MAP = { @@ -107,15 +108,14 @@ export function Footer() { {REGISTRY_CONTRACT_ID && (

{t('contractOnStellar', { network: STELLAR_NETWORK })}{' '} - {REGISTRY_CONTRACT_ID.slice(0, 8)}…{REGISTRY_CONTRACT_ID.slice(-8)} - +

)} diff --git a/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx b/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx index 20a5102ee..e5f3a6e4f 100644 --- a/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx +++ b/invofi/apps/frontend/src/components/marketplace/MarketplaceCard.tsx @@ -1,13 +1,12 @@ import Link from 'next/link'; import { Calendar, DollarSign, ArrowRight, ExternalLink, Clock, AlertTriangle } from 'lucide-react'; +import { ExplorerLink } from '@/components/common/ExplorerLink'; import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { formatAmount, formatDate, formatAddress, INVOICE_STATUS_COLORS } from '@/lib/utils'; import type { Invoice } from '@/types'; -const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'; -const STELLAR_EXPERT = `https://stellar.expert/explorer/${NETWORK}`; function DueLabel({ dueDateUnix }: { dueDateUnix: number }) { const now = Date.now() / 1000; @@ -47,16 +46,14 @@ export function MarketplaceCard({ invoice }: MarketplaceCardProps) {

{invoice.id}

- e.stopPropagation()} className="text-muted-foreground hover:text-blue-500 transition-colors shrink-0" > - +
{invoice.status}
@@ -76,14 +73,13 @@ export function MarketplaceCard({ invoice }: MarketplaceCardProps) {

Originator:{' '} - {formatAddress(invoice.originator)} - +

diff --git a/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx b/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx index 7195496fe..df955722c 100644 --- a/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx +++ b/invofi/apps/frontend/src/components/marketplace/PositionListingCard.tsx @@ -7,11 +7,10 @@ import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { LISTING_STATUS_COLORS, unitPrice } from '@/lib/listings'; +import { ExplorerLink } from '@/components/common/ExplorerLink'; import { formatAddress } from '@/lib/utils'; import type { PositionListing, PositionListingStatus } from '@/types'; -const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'; -const STELLAR_EXPERT = `https://stellar.expert/explorer/${NETWORK}`; interface PositionListingCardProps { listing: PositionListing; @@ -71,14 +70,13 @@ export function PositionListingCard({ listing, isOwn, onStatusChange, busy }: Po

Seller:{' '} - {formatAddress(listing.seller)} - +

{listing.note && ( diff --git a/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx b/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx index 509101c21..b3292b630 100644 --- a/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx +++ b/invofi/apps/frontend/src/components/marketplace/SuggestedMatches.tsx @@ -34,10 +34,9 @@ import { cn } from '@/lib/utils'; import { formatAmount, formatDate, formatAddress, INVOICE_STATUS_COLORS } from '@/lib/utils'; import { MatchQualityBadge } from '@/components/marketplace/MatchQualityBadge'; import type { MatchResult, ScoreBreakdown } from '@/types/matching'; +import { ExplorerLink } from '@/components/common/ExplorerLink'; import type { Invoice } from '@/types'; -const NETWORK = process.env.NEXT_PUBLIC_STELLAR_NETWORK === 'mainnet' ? 'mainnet' : 'testnet'; -const STELLAR_EXPERT = `https://stellar.expert/explorer/${NETWORK}`; // ── Due label (extracted from MarketplaceCard, kept consistent) ─────────────── @@ -140,16 +139,14 @@ function MatchedInvoiceCard({ result }: MatchedInvoiceCardProps) {

{invoice.id}

- e.stopPropagation()} className="text-muted-foreground hover:text-blue-500 transition-colors shrink-0" > - +
@@ -193,14 +190,13 @@ function MatchedInvoiceCard({ result }: MatchedInvoiceCardProps) {

Originator:{' '} - {formatAddress(invoice.originator)} - +