diff --git a/components/dashboard/contract-escrow-summary.tsx b/components/dashboard/contract-escrow-summary.tsx index 264972e..f3ec6ce 100644 --- a/components/dashboard/contract-escrow-summary.tsx +++ b/components/dashboard/contract-escrow-summary.tsx @@ -7,6 +7,8 @@ import { EscrowStatusTracker, type EscrowStage, } from "@/components/dashboard/escrow-status-tracker"; +import { ContractAddress, TransactionHash } from "@/components/stellar"; +import { networkFromPassphrase } from "@/lib/stellar/explorer"; interface EscrowInfo { escrow_address: string | null; @@ -32,6 +34,8 @@ export function ContractEscrowSummary({ const escrowStage: EscrowStage = (escrow?.escrow_status as EscrowStage | undefined) ?? "Funded"; + const network = networkFromPassphrase(escrow?.network_passphrase); + return (
@@ -61,7 +65,14 @@ export function ContractEscrowSummary({ {escrow.escrow_address && (

- Address: {escrow.escrow_address} + Address: +

)} {escrow.network_passphrase && ( @@ -96,8 +107,16 @@ export function ContractEscrowSummary({
{escrow.funding_tx_hash && ( -

- Funding Tx: {escrow.funding_tx_hash} +

+ Funding Tx: +

)} diff --git a/components/freelancer/freelancer-profile.tsx b/components/freelancer/freelancer-profile.tsx index fdcd533..b8f6671 100644 --- a/components/freelancer/freelancer-profile.tsx +++ b/components/freelancer/freelancer-profile.tsx @@ -6,7 +6,6 @@ import { CheckCircle2, Clock, Copy, - ExternalLink, Shield, Star, TrendingUp, @@ -16,6 +15,7 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Progress } from '@/components/ui/progress' import type { FreelancerReputationPayload } from '@/lib/reputation' +import { WalletAddress } from '@/components/stellar' const MOCK_SKILLS = [ 'Next.js', @@ -275,15 +275,13 @@ export function FreelancerProfile() { )}
{walletAddress && ( - - - View on Explorer - + )} diff --git a/components/stellar/BlockInfo.tsx b/components/stellar/BlockInfo.tsx new file mode 100644 index 0000000..92b962d --- /dev/null +++ b/components/stellar/BlockInfo.tsx @@ -0,0 +1,109 @@ +'use client' + +import { Blocks } from 'lucide-react' +import { cn } from '@/lib/utils' +import { getLedgerUrl } from '@/lib/stellar/explorer' +import { ExplorerLink } from './ExplorerLink' +import type { StellarNetwork } from '@/components/wallet-provider' + +export interface BlockInfoProps { + /** The ledger sequence number */ + ledgerSeq: number | string + /** The network environment */ + network: StellarNetwork + /** Additional CSS classes */ + className?: string + /** Size variant */ + size?: 'sm' | 'md' | 'lg' + /** Optional label to display before the block info */ + label?: string + /** Whether to show the explorer link */ + showExplorerLink?: boolean + /** Whether to show the block icon */ + showIcon?: boolean + /** Optional timestamp to display alongside the block */ + timestamp?: string +} + +/** + * Displays block/ledger information with a direct reference link + * to Stellar Explorer. + * + * @example + * ```tsx + * + * ``` + */ +export function BlockInfo({ + ledgerSeq, + network, + className, + size = 'sm', + label, + showExplorerLink = true, + showIcon = true, + timestamp, +}: BlockInfoProps) { + const explorerUrl = showExplorerLink + ? getLedgerUrl(ledgerSeq, network) + : undefined + + const sizeClasses = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', + } + + const formattedTimestamp = timestamp + ? new Date(timestamp).toLocaleString() + : undefined + + return ( + + {/* Block icon */} + {showIcon && ( + + )} + + {/* Optional label */} + {label && ( + {label}: + )} + + {/* Explorer link or plain text */} + {explorerUrl ? ( + + + #{ledgerSeq} + + + ) : ( + #{ledgerSeq} + )} + + {/* Timestamp */} + {formattedTimestamp && ( + + {formattedTimestamp} + + )} + + ) +} \ No newline at end of file diff --git a/components/stellar/ContractAddress.tsx b/components/stellar/ContractAddress.tsx new file mode 100644 index 0000000..550361d --- /dev/null +++ b/components/stellar/ContractAddress.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Copy, Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + getContractUrl, + truncateAddress, + copyToClipboard, +} from '@/lib/stellar/explorer' +import { ExplorerLink } from './ExplorerLink' +import type { StellarNetwork } from '@/components/wallet-provider' + +export interface ContractAddressProps { + /** The full contract address (typically C... Stellar address) */ + address: string + /** The network environment */ + network: StellarNetwork + /** Whether to show the copy button */ + showCopy?: boolean + /** Whether to show the explorer link */ + showExplorerLink?: boolean + /** Additional CSS classes */ + className?: string + /** Size variant */ + size?: 'sm' | 'md' | 'lg' + /** Optional label to display before the address */ + label?: string + /** Callback when the address is copied to clipboard */ + onCopy?: () => void +} + +/** + * Displays a contract address with a clickable link to Stellar Explorer + * and optional copy-to-clipboard functionality. + * + * @example + * ```tsx + * + * ``` + */ +export function ContractAddress({ + address, + network, + showCopy = true, + showExplorerLink = true, + className, + size = 'sm', + label, + onCopy, +}: ContractAddressProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(async () => { + const success = await copyToClipboard(address) + if (success) { + setCopied(true) + onCopy?.() + setTimeout(() => setCopied(false), 2000) + } + }, [address, onCopy]) + + const explorerUrl = showExplorerLink ? getContractUrl(address, network) : undefined + + const sizeClasses = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', + } + + return ( + + {/* Optional label */} + {label && ( + {label}: + )} + + {/* Explorer link */} + {explorerUrl ? ( + + {truncateAddress(address)} + + ) : ( + + {truncateAddress(address)} + + )} + + {/* Copy to clipboard button */} + {showCopy && ( + + )} + + ) +} \ No newline at end of file diff --git a/components/stellar/ExplorerLink.tsx b/components/stellar/ExplorerLink.tsx new file mode 100644 index 0000000..3349b8f --- /dev/null +++ b/components/stellar/ExplorerLink.tsx @@ -0,0 +1,67 @@ +'use client' + +import { ExternalLink } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { StellarNetwork } from '@/components/wallet-provider' + +export interface ExplorerLinkProps { + /** The URL to open in the explorer */ + href: string + /** The display label for the link */ + label?: string + /** Network badge to show (testnet/mainnet) */ + network?: StellarNetwork + /** Additional CSS classes */ + className?: string + /** Whether to show the external link icon */ + showIcon?: boolean + /** Size variant */ + size?: 'sm' | 'md' | 'lg' + /** Children to render inside the link (overrides label) */ + children?: React.ReactNode +} + +/** + * Base component for external explorer links. + * Opens links in a new tab without disrupting the app session. + */ +export function ExplorerLink({ + href, + label, + network, + className, + showIcon = true, + size = 'sm', + children, +}: ExplorerLinkProps) { + const sizeClasses = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', + } + + return ( + { + // Stop propagation to prevent parent click handlers from firing + e.stopPropagation() + }} + > + {children ?? label ?? href} + {showIcon && } + {network && network !== 'UNKNOWN' && ( + + {network === 'TESTNET' ? 'Testnet' : 'Mainnet'} + + )} + + ) +} \ No newline at end of file diff --git a/components/stellar/TransactionHash.tsx b/components/stellar/TransactionHash.tsx new file mode 100644 index 0000000..a5d2b82 --- /dev/null +++ b/components/stellar/TransactionHash.tsx @@ -0,0 +1,123 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Copy, Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + getTransactionUrl, + truncateHash, + copyToClipboard, +} from '@/lib/stellar/explorer' +import { ExplorerLink } from './ExplorerLink' +import type { StellarNetwork } from '@/components/wallet-provider' + +export interface TransactionHashProps { + /** The full transaction hash (64-char hex string) */ + hash: string + /** The network environment */ + network: StellarNetwork + /** Number of characters to show at start and end (default: 6) */ + truncateChars?: number + /** Whether to show the copy button */ + showCopy?: boolean + /** Whether to show the explorer link */ + showExplorerLink?: boolean + /** Additional CSS classes */ + className?: string + /** Size variant */ + size?: 'sm' | 'md' | 'lg' + /** Callback when the hash is copied to clipboard */ + onCopy?: () => void +} + +/** + * Displays a shortened transaction hash with copy-to-clipboard functionality + * and a link to open the transaction on Stellar Explorer. + * + * @example + * ```tsx + * + * ``` + */ +export function TransactionHash({ + hash, + network, + truncateChars = 6, + showCopy = true, + showExplorerLink = true, + className, + size = 'sm', + onCopy, +}: TransactionHashProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(async () => { + const success = await copyToClipboard(hash) + if (success) { + setCopied(true) + onCopy?.() + setTimeout(() => setCopied(false), 2000) + } + }, [hash, onCopy]) + + const explorerUrl = showExplorerLink ? getTransactionUrl(hash, network) : undefined + + const sizeClasses = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', + } + + return ( + + {/* Shortened hash */} + + {truncateHash(hash, truncateChars)} + + + {/* Copy to clipboard button */} + {showCopy && ( + + )} + + {/* Explorer link */} + {explorerUrl && ( + + )} + + ) +} \ No newline at end of file diff --git a/components/stellar/WalletAddress.tsx b/components/stellar/WalletAddress.tsx new file mode 100644 index 0000000..b98605c --- /dev/null +++ b/components/stellar/WalletAddress.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState, useCallback } from 'react' +import { Copy, Check } from 'lucide-react' +import { cn } from '@/lib/utils' +import { + getAccountUrl, + truncateAddress, + copyToClipboard, +} from '@/lib/stellar/explorer' +import { ExplorerLink } from './ExplorerLink' +import type { StellarNetwork } from '@/components/wallet-provider' + +export interface WalletAddressProps { + /** The full wallet address (Stellar public key G... or C...) */ + address: string + /** The network environment */ + network: StellarNetwork + /** Whether to show the copy button */ + showCopy?: boolean + /** Whether to show the explorer link */ + showExplorerLink?: boolean + /** Additional CSS classes */ + className?: string + /** Size variant */ + size?: 'sm' | 'md' | 'lg' + /** Optional label to display before the address */ + label?: string + /** Callback when the address is copied to clipboard */ + onCopy?: () => void +} + +/** + * Displays a wallet address with a clickable link to Stellar Explorer + * and optional copy-to-clipboard functionality. + * + * @example + * ```tsx + * + * ``` + */ +export function WalletAddress({ + address, + network, + showCopy = true, + showExplorerLink = true, + className, + size = 'sm', + label, + onCopy, +}: WalletAddressProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(async () => { + const success = await copyToClipboard(address) + if (success) { + setCopied(true) + onCopy?.() + setTimeout(() => setCopied(false), 2000) + } + }, [address, onCopy]) + + const explorerUrl = showExplorerLink ? getAccountUrl(address, network) : undefined + + const sizeClasses = { + sm: 'text-xs', + md: 'text-sm', + lg: 'text-base', + } + + return ( + + {/* Optional label */} + {label && ( + {label}: + )} + + {/* Explorer link */} + {explorerUrl ? ( + + {truncateAddress(address)} + + ) : ( + + {truncateAddress(address)} + + )} + + {/* Copy to clipboard button */} + {showCopy && ( + + )} + + ) +} \ No newline at end of file diff --git a/components/stellar/index.ts b/components/stellar/index.ts new file mode 100644 index 0000000..6b35bd1 --- /dev/null +++ b/components/stellar/index.ts @@ -0,0 +1,22 @@ +/** + * Stellar Explorer Integration Components + * + * Reusable components for displaying Stellar blockchain data with + * explorer links, copy-to-clipboard functionality, and network-aware + * URL generation. + */ + +export { ExplorerLink } from './ExplorerLink' +export type { ExplorerLinkProps } from './ExplorerLink' + +export { TransactionHash } from './TransactionHash' +export type { TransactionHashProps } from './TransactionHash' + +export { ContractAddress } from './ContractAddress' +export type { ContractAddressProps } from './ContractAddress' + +export { WalletAddress } from './WalletAddress' +export type { WalletAddressProps } from './WalletAddress' + +export { BlockInfo } from './BlockInfo' +export type { BlockInfoProps } from './BlockInfo' \ No newline at end of file diff --git a/components/wallet-connect.tsx b/components/wallet-connect.tsx index 35f81b5..caa5a38 100644 --- a/components/wallet-connect.tsx +++ b/components/wallet-connect.tsx @@ -2,7 +2,8 @@ import { Button } from "@/components/ui/button"; import { Wallet, LogOut } from "lucide-react"; -import { useFreighter, truncateStellarAddress } from "@/lib/hooks/use-freighter"; +import { useFreighter } from "@/lib/hooks/use-freighter"; +import { WalletAddress } from "@/components/stellar"; export function WalletConnect() { const { @@ -49,9 +50,13 @@ export function WalletConnect() { {isWrongNetwork && ( Wrong network ({network}) )} - - {truncateStellarAddress(address)} - + @@ -65,4 +70,4 @@ export function WalletConnect() { {isConnecting ? "Connecting…" : "Connect Wallet"} ); -} +} \ No newline at end of file diff --git a/components/wallet-status.tsx b/components/wallet-status.tsx index 35a1478..ddd9fde 100644 --- a/components/wallet-status.tsx +++ b/components/wallet-status.tsx @@ -8,6 +8,7 @@ import { getAddress, getNetwork, } from "@stellar/freighter-api"; +import { WalletAddress } from "@/components/stellar"; type NetworkType = "PUBLIC" | "TESTNET" | "UNKNOWN"; @@ -61,11 +62,6 @@ export function WalletStatus() { return () => clearInterval(interval); }, [checkWalletStatus]); - const truncateAddress = (address: string): string => { - if (address.length <= 10) return address; - return `${address.slice(0, 5)}...${address.slice(-4)}`; - }; - const getNetworkBadgeVariant = (network: NetworkType) => { switch (network) { case "PUBLIC": @@ -104,12 +100,16 @@ export function WalletStatus() {
- - {truncateAddress(walletState.publicKey)} - + {getNetworkLabel(walletState.network)}
); -} +} \ No newline at end of file diff --git a/lib/stellar/explorer.ts b/lib/stellar/explorer.ts new file mode 100644 index 0000000..874fef1 --- /dev/null +++ b/lib/stellar/explorer.ts @@ -0,0 +1,254 @@ +/** + * Stellar Explorer Integration + * + * Provides utilities for generating Stellar Explorer links and formatting + * blockchain data throughout the application. + * + * Supports both Testnet and Mainnet (Public) network environments. + */ + +import type { StellarNetwork } from '@/components/wallet-provider' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const EXPLORER_BASE_URLS: Record = { + TESTNET: 'https://stellar.expert/explorer/testnet', + PUBLIC: 'https://stellar.expert/explorer/public', + UNKNOWN: 'https://stellar.expert/explorer/testnet', // Default to testnet +} + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface ExplorerLinkConfig { + /** The network environment (testnet, public, or unknown) */ + network: StellarNetwork + /** Optional label override for the link */ + label?: string + /** Additional CSS classes for the link */ + className?: string +} + +// --------------------------------------------------------------------------- +// URL Generators +// --------------------------------------------------------------------------- + +/** + * Get the base explorer URL for the given network. + */ +export function getExplorerBaseUrl(network: StellarNetwork): string { + return EXPLORER_BASE_URLS[network] || EXPLORER_BASE_URLS.TESTNET +} + +/** + * Generate a full explorer URL for a transaction hash. + * + * @example + * getTransactionUrl('abc123...', 'TESTNET') + * // => 'https://stellar.expert/explorer/testnet/tx/abc123...' + */ +export function getTransactionUrl(hash: string, network: StellarNetwork): string { + const base = getExplorerBaseUrl(network) + return `${base}/tx/${hash}` +} + +/** + * Generate a full explorer URL for an account / wallet address. + * + * @example + * getAccountUrl('GABCD...', 'PUBLIC') + * // => 'https://stellar.expert/explorer/public/account/GABCD...' + */ +export function getAccountUrl(address: string, network: StellarNetwork): string { + const base = getExplorerBaseUrl(network) + return `${base}/account/${address}` +} + +/** + * Generate a full explorer URL for a contract address. + * On Stellar, contracts are identified by their contract ID. + * + * @example + * getContractUrl('CC123...', 'TESTNET') + * // => 'https://stellar.expert/explorer/testnet/contract/CC123...' + */ +export function getContractUrl(contractId: string, network: StellarNetwork): string { + const base = getExplorerBaseUrl(network) + return `${base}/contract/${contractId}` +} + +/** + * Generate a full explorer URL for a ledger (block). + * + * @example + * getLedgerUrl(123456, 'PUBLIC') + * // => 'https://stellar.expert/explorer/public/ledger/123456' + */ +export function getLedgerUrl(ledgerSeq: number | string, network: StellarNetwork): string { + const base = getExplorerBaseUrl(network) + return `${base}/ledger/${ledgerSeq}` +} + +/** + * Generate a full explorer URL for an asset. + * + * @example + * getAssetUrl('USDC', 'GC...', 'TESTNET') + * // => 'https://stellar.expert/explorer/testnet/asset/USDC-GC...' + */ +export function getAssetUrl( + assetCode: string, + assetIssuer: string, + network: StellarNetwork +): string { + const base = getExplorerBaseUrl(network) + return `${base}/asset/${assetCode}-${assetIssuer}` +} + +// --------------------------------------------------------------------------- +// Formatting Helpers +// --------------------------------------------------------------------------- + +/** + * Truncate a hash or address for display purposes. + * + * @param hash - The full hash or address string. + * @param chars - Number of characters to keep at the start and end (default: 4). + * @returns The truncated string, e.g. "abcde...xyzw" + * + * @example + * truncateHash('GABCDEF1234567890XYZ') // => 'GABC...XYZ0' + * truncateHash('abc123def456', 3) // => 'abc...456' + */ +export function truncateHash(hash: string, chars = 4): string { + if (!hash) return '' + if (hash.length <= chars * 2 + 3) return hash + return `${hash.slice(0, chars + 1)}...${hash.slice(-chars)}` +} + +/** + * Truncate a Stellar account address (G... or C... public key) for display. + * Stellar addresses are 56 characters long, so we show first 5 and last 4. + * + * @example + * truncateAddress('GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNOPQRSTUVWX') + * // => 'GABCD...WX' + */ +export function truncateAddress(address: string): string { + if (!address) return '' + if (address.length <= 10) return address + return `${address.slice(0, 5)}...${address.slice(-4)}` +} + +/** + * Determine if the given hash is a Stellar transaction hash. + * Stellar transaction hashes are 64-character hex strings. + */ +export function isTransactionHash(hash: string): boolean { + return /^[a-f0-9]{64}$/i.test(hash) +} + +/** + * Determine if the given string is a Stellar public key (G... or C... account). + * Stellar account addresses are 56 characters starting with G or C. + */ +export function isStellarAddress(address: string): boolean { + return /^[GC][1-9A-HJ-NP-Za-km-z]{55}$/.test(address) +} + +/** + * Determine if the given string is a Stellar contract ID. + * Contract IDs are typically 56 characters starting with C. + */ +export function isContractId(contractId: string): boolean { + return /^C[1-9A-HJ-NP-Za-km-z]{55}$/.test(contractId) +} + +// --------------------------------------------------------------------------- +// Clipboard Helper +// --------------------------------------------------------------------------- + +/** + * Copy the given text to the clipboard. + * Returns a promise that resolves to true if the copy was successful. + */ +export async function copyToClipboard(text: string): Promise { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text) + return true + } + + // Fallback for older browsers + const textArea = document.createElement('textarea') + textArea.value = text + textArea.style.position = 'fixed' + textArea.style.left = '-9999px' + textArea.style.top = '-9999px' + document.body.appendChild(textArea) + textArea.focus() + textArea.select() + + const success = document.execCommand('copy') + document.body.removeChild(textArea) + return success + } catch { + return false + } +} + +// --------------------------------------------------------------------------- +// Network Helpers +// --------------------------------------------------------------------------- + +/** + * Get a human-readable label for the network type. + */ +export function getNetworkLabel(network: StellarNetwork): string { + const labels: Record = { + TESTNET: 'Testnet', + PUBLIC: 'Mainnet', + UNKNOWN: 'Unknown', + } + return labels[network] || 'Unknown' +} + +/** + * Determine if the network is a testnet. + */ +export function isTestnet(network: StellarNetwork): boolean { + return network === 'TESTNET' +} + +/** + * Determine if the network is mainnet (public). + */ +export function isMainnet(network: StellarNetwork): boolean { + return network === 'PUBLIC' +} + +/** + * Known Stellar network passphrases. + */ +const NETWORK_PASSPHRASES: Record = { + 'Test SDF Network ; September 2015': 'TESTNET', + 'Public Global Stellar Network ; September 2015': 'PUBLIC', +} + +/** + * Convert a Stellar network passphrase to a StellarNetwork type. + * + * @param passphrase - The network passphrase string (e.g. "Test SDF Network ; September 2015") + * @returns The corresponding StellarNetwork type, or 'UNKNOWN' if not recognized. + * + * @example + * networkFromPassphrase('Test SDF Network ; September 2015') // => 'TESTNET' + * networkFromPassphrase('Public Global Stellar Network ; September 2015') // => 'PUBLIC' + */ +export function networkFromPassphrase(passphrase: string | null | undefined): StellarNetwork { + if (!passphrase) return 'UNKNOWN' + return NETWORK_PASSPHRASES[passphrase] || 'UNKNOWN' +}