diff --git a/frontend/src/components/Leaderboard.jsx b/frontend/src/components/Leaderboard.jsx index 2e8569bd..c3f59282 100644 --- a/frontend/src/components/Leaderboard.jsx +++ b/frontend/src/components/Leaderboard.jsx @@ -1,6 +1,6 @@ import { useEffect, useState, useCallback } from 'react'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; -import { formatSTX } from '../lib/utils'; +import { formatSTX, formatAddress } from '../lib/utils'; import CopyButton from './ui/copy-button'; const API_BASE = 'https://api.hiro.so'; @@ -72,7 +72,7 @@ export default function Leaderboard() { return b.totalReceived - a.totalReceived; }).slice(0, 20); - const truncateAddress = (addr) => `${addr.slice(0, 8)}...${addr.slice(-6)}`; + const truncateAddress = (addr) => formatAddress(addr, 8, 6); if (loading) { return ( diff --git a/frontend/src/components/NotificationBell.jsx b/frontend/src/components/NotificationBell.jsx index b0a6133b..4a5031e1 100644 --- a/frontend/src/components/NotificationBell.jsx +++ b/frontend/src/components/NotificationBell.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect } from 'react'; -import { formatSTX } from '../lib/utils'; +import { formatSTX, formatAddress } from '../lib/utils'; export default function NotificationBell({ notifications, unreadCount, onMarkRead, loading }) { const [open, setOpen] = useState(false); @@ -22,8 +22,7 @@ export default function NotificationBell({ notifications, unreadCount, onMarkRea } }; - const truncateAddr = (addr) => - addr ? `${addr.slice(0, 6)}...${addr.slice(-4)}` : ''; + const truncateAddr = (addr) => formatAddress(addr, 6, 4); return (
diff --git a/frontend/src/components/RecentTips.jsx b/frontend/src/components/RecentTips.jsx index d201ac5b..683d80a3 100644 --- a/frontend/src/components/RecentTips.jsx +++ b/frontend/src/components/RecentTips.jsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { openContractCall } from '@stacks/connect'; import { uintCV, stringUtf8CV, PostConditionMode, Pc } from '@stacks/transactions'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; -import { formatSTX, toMicroSTX } from '../lib/utils'; +import { formatSTX, toMicroSTX, formatAddress } from '../lib/utils'; import { network, appDetails, userSession } from '../utils/stacks'; import { useTipContext } from '../context/TipContext'; import CopyButton from './ui/copy-button'; @@ -102,7 +102,7 @@ export default function RecentTips({ addToast }) { const truncateAddress = (address) => { const addrStr = typeof address === 'string' ? address : (address.value || ''); - return `${addrStr.slice(0, 8)}...${addrStr.slice(-6)}`; + return formatAddress(addrStr, 8, 6); }; const fullAddress = (address) => { diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx index 2d6c266a..519a9812 100644 --- a/frontend/src/components/TipHistory.jsx +++ b/frontend/src/components/TipHistory.jsx @@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react'; import { fetchCallReadOnlyFunction, cvToJSON, principalCV } from '@stacks/transactions'; import { network } from '../utils/stacks'; import { CONTRACT_ADDRESS, CONTRACT_NAME } from '../config/contracts'; -import { formatSTX } from '../lib/utils'; +import { formatSTX, formatAddress } from '../lib/utils'; import { useTipContext } from '../context/TipContext'; import CopyButton from './ui/copy-button'; import ShareTip from './ShareTip'; @@ -123,7 +123,7 @@ export default function TipHistory({ userAddress }) { } }; - const truncateAddr = (addr) => `${addr.slice(0, 8)}...${addr.slice(-6)}`; + const truncateAddr = (addr) => formatAddress(addr, 8, 6); const filteredTips = tips.filter(t => { if (tab === 'sent' && t.direction !== 'sent') return false; @@ -196,7 +196,7 @@ export default function TipHistory({ userAddress }) { {stats['tips-sent'].value}

- Total Volume: {(stats['total-sent'].value / 1000000).toFixed(2)} STX + Total Volume: {formatSTX(stats['total-sent'].value, 2)} STX

@@ -213,7 +213,7 @@ export default function TipHistory({ userAddress }) { {stats['tips-received'].value}

- Total Earned: {(stats['total-received'].value / 1000000).toFixed(2)} STX + Total Earned: {formatSTX(stats['total-received'].value, 2)} STX

diff --git a/frontend/src/lib/utils.js b/frontend/src/lib/utils.js index 7e08a0e0..8d45d25a 100644 --- a/frontend/src/lib/utils.js +++ b/frontend/src/lib/utils.js @@ -26,3 +26,25 @@ export function formatSTX(microStx, decimals = 6) { export function toMicroSTX(stx) { return Math.floor(parseFloat(stx) * MICRO_STX); } + +/** + * Truncate a Stacks address for display. + * @param {string} address - Full address + * @param {number} [startChars=6] - Characters to show from start + * @param {number} [endChars=4] - Characters to show from end + * @returns {string} Truncated address + */ +export function formatAddress(address, startChars = 6, endChars = 4) { + if (!address || address.length <= startChars + endChars + 3) return address || ''; + return `${address.slice(0, startChars)}...${address.slice(-endChars)}`; +} + +/** + * Locale-aware number formatting. + * @param {number|string} n - Number to format + * @param {object} [options] - Intl.NumberFormat options + * @returns {string} Formatted number + */ +export function formatNumber(n, options = {}) { + return Number(n).toLocaleString(undefined, options); +} diff --git a/frontend/src/test/utils.test.js b/frontend/src/test/utils.test.js index 9441e5b2..de571d5d 100644 --- a/frontend/src/test/utils.test.js +++ b/frontend/src/test/utils.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { formatSTX, toMicroSTX, cn } from '../lib/utils'; +import { formatSTX, toMicroSTX, cn, formatAddress, formatNumber } from '../lib/utils'; describe('formatSTX', () => { it('converts micro-STX to STX string', () => { @@ -59,3 +59,48 @@ describe('cn', () => { expect(result).not.toContain('hidden'); }); }); + +describe('formatAddress', () => { + it('truncates a standard Stacks address', () => { + const addr = 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'; + expect(formatAddress(addr)).toBe('SP31PK...2W5T'); + }); + + it('uses custom start and end lengths', () => { + const addr = 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T'; + expect(formatAddress(addr, 8, 6)).toBe('SP31PKQV...VS2W5T'); + }); + + it('returns short addresses unmodified', () => { + expect(formatAddress('SP123')).toBe('SP123'); + }); + + it('handles empty or null input', () => { + expect(formatAddress('')).toBe(''); + expect(formatAddress(null)).toBe(''); + expect(formatAddress(undefined)).toBe(''); + }); +}); + +describe('formatNumber', () => { + it('formats a number with locale separators', () => { + const result = formatNumber(1234567); + expect(result).toContain('1'); + expect(result.length).toBeGreaterThan(3); + }); + + it('formats with decimal options', () => { + const result = formatNumber(1234.5, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + expect(result).toContain('34'); + expect(result).toContain('50'); + }); + + it('handles zero', () => { + expect(formatNumber(0)).toBe('0'); + }); + + it('handles string input', () => { + const result = formatNumber('999'); + expect(result).toContain('999'); + }); +});