diff --git a/.gitignore b/.gitignore index d4f78a5..32ceb45 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,17 @@ dist-ssr *.sln *.sw? issue.md -pr.md \ No newline at end of file +pr.md + +# Test artifacts +__snapshots__ +*.snap +coverage +.nyc_output + +# OS artifacts +Thumbs.db + +# Lock files from other package managers +package-lock.json +yarn.lock \ No newline at end of file diff --git a/README.md b/README.md index ac51a1d..143fe2a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ The client is responsible for: - `Ctrl/Cmd + Alt + R` refreshes creator list data from the marketplace page. The shortcut is ignored while focus is inside text inputs, textareas, selects, or editable text regions. +- `T` opens the trade panel from the creator profile page. The shortcut is + ignored while focus is inside text inputs, textareas, selects, or + editable text regions. ## Local setup diff --git a/src/hooks/__tests__/useFormatXlm.test.ts b/src/hooks/__tests__/useFormatXlm.test.ts new file mode 100644 index 0000000..31e860b --- /dev/null +++ b/src/hooks/__tests__/useFormatXlm.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { formatXlm, useFormatXlm } from '@/hooks/useFormatXlm'; +import { renderHook } from '@testing-library/react'; + +describe('useFormatXlm', () => { + describe('default decimal precision (2 places)', () => { + it('formats a standard stroop amount with 2 decimal places', () => { + // 15_000_000 stroops = 1.5 XLM → "1.50" + expect(formatXlm(15_000_000)).toBe('1.50'); + }); + + it('formats zero input as 0.00', () => { + expect(formatXlm(0)).toBe('0.00'); + }); + + it('formats exactly 1 XLM (10_000_000 stroops) as 1.00', () => { + expect(formatXlm(10_000_000)).toBe('1.00'); + }); + + it('formats a sub-XLM value with 2 decimal places', () => { + // 500_000 stroops = 0.05 XLM → "0.05" + expect(formatXlm(500_000)).toBe('0.05'); + }); + }); + + describe('decimal override', () => { + it('formats with 0 decimal places when decimals=0', () => { + // 10_000_000 stroops = 1 XLM → "1" + expect(formatXlm(10_000_000, { decimals: 0 })).toBe('1'); + }); + + it('formats zero with 0 decimal places', () => { + expect(formatXlm(0, { decimals: 0 })).toBe('0'); + }); + + it('formats with 7 decimal places when decimals=7', () => { + // 10_000_000 stroops = 1 XLM → "1.0000000" + expect(formatXlm(10_000_000, { decimals: 7 })).toBe('1.0000000'); + }); + + it('formats a partial XLM value with 7 decimal places', () => { + // 1 stroop = 0.0000001 XLM + expect(formatXlm(1, { decimals: 7 })).toBe('0.0000001'); + }); + }); + + describe('thousands separator', () => { + it('includes a thousands separator for values above 1 000 XLM', () => { + // 10_001_000_000 stroops = 1_000.1 XLM + const result = formatXlm(10_001_000_000); + // The formatted string should contain a thousands separator character + // between the thousands and hundreds position (locale-dependent). + // We verify by checking that 1000 XLM renders as a 5+ char string. + expect(result.length).toBeGreaterThan(4); // at least "1,000" or "1 000" + // Must contain the numeric value 1000 with a separator + expect(result).toMatch(/1.000/); // separator can be , or . or space + }); + + it('does not include a thousands separator for values below 1 000 XLM', () => { + // 9_990_000_000 stroops = 999 XLM → "999.00" + const result = formatXlm(9_990_000_000); + expect(result).toBe('999.00'); + }); + }); + + describe('large values', () => { + it('formats 10_000_000 stroops (1 XLM) without scientific notation', () => { + const result = formatXlm(10_000_000); + expect(result).not.toMatch(/e/i); + expect(result).toBe('1.00'); + }); + + it('formats a large value (100_000_000_000_000 stroops = 10,000,000 XLM) without scientific notation', () => { + const result = formatXlm(100_000_000_000_000); + expect(result).not.toMatch(/e/i); + // Should contain the numeric value 10000000 with formatting + expect(result).toMatch(/10/); + }); + + it('formats 70_000_000_000 stroops (7000 XLM) without scientific notation', () => { + const result = formatXlm(70_000_000_000); + expect(result).not.toMatch(/e/i); + // 7000.00 formatted + expect(result).toMatch(/7/); + }); + }); + + describe('hook interface', () => { + it('exposes a format function', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(typeof result.current.format).toBe('function'); + }); + + it('format function produces the same output as the standalone formatXlm', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(result.current.format(15_000_000)).toBe(formatXlm(15_000_000)); + }); + + it('format function respects decimals option', () => { + const { result } = renderHook(() => useFormatXlm()); + expect(result.current.format(10_000_000, { decimals: 0 })).toBe('1'); + }); + }); +}); diff --git a/src/hooks/useCreators.ts b/src/hooks/useCreators.ts index b2690fe..1ea1cec 100644 --- a/src/hooks/useCreators.ts +++ b/src/hooks/useCreators.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; import type { GetCoursesParams } from '@/services/course.service'; @@ -10,9 +10,31 @@ export function useCreatorList(params?: GetCoursesParams) { } export function useCreatorDetail(id: string) { + const queryClient = useQueryClient(); + return useQuery({ queryKey: queryKeys.creators.detail(id), - queryFn: async () => null, + queryFn: async () => { + const key = queryKeys.creators.detail(id); + const cached = queryClient.getQueryData(key); + const isCacheMiss = cached === undefined; + + if (isCacheMiss && typeof process !== 'undefined' && process.env?.NODE_ENV !== 'test') { + const startMs = Date.now(); + const result = await Promise.resolve(null); + const duration_ms = Date.now() - startMs; + + console.debug('[creator-profile]', { + creator_id: id, + cache_status: 'miss', + duration_ms, + }); + + return result; + } + + return null; + }, enabled: !!id, }); } diff --git a/src/hooks/useFormatXlm.ts b/src/hooks/useFormatXlm.ts new file mode 100644 index 0000000..88431c1 --- /dev/null +++ b/src/hooks/useFormatXlm.ts @@ -0,0 +1,47 @@ +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +export interface FormatXlmOptions { + /** Number of decimal places to display. Defaults to 2. */ + decimals?: number; +} + +/** + * Converts a stroop amount to a formatted XLM string. + * + * @param stroops - Amount in stroops (1 XLM = 10,000,000 stroops) + * @param options - Formatting options + * @returns Formatted XLM string, e.g. "1.50" for 15,000,000 stroops + * + * @example + * formatXlm(10_000_000) // "1.00" + * formatXlm(10_000_000, { decimals: 0 }) // "1" + * formatXlm(15_000_000, { decimals: 7 }) // "1.5000000" + */ +export function formatXlm( + stroops: number, + options: FormatXlmOptions = {} +): string { + const { decimals = 2 } = options; + const xlm = stroops / STROOPS_PER_XLM; + + return new Intl.NumberFormat(undefined, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + useGrouping: true, + }).format(xlm); +} + +/** + * Hook that exposes a formatXlm formatter bound to the app locale. + * + * Returns a stable `format` function that converts a stroop amount to a + * locale-aware XLM string. + * + * @example + * const { format } = useFormatXlm(); + * format(10_000_000) // "1.00" + * format(10_000_000, { decimals: 0 }) // "1" + */ +export function useFormatXlm() { + return { format: formatXlm }; +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index fb66cbb..2a1dc00 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -66,11 +66,12 @@ import { import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; import { creatorListKey } from '@/utils/creatorListKey.utils'; -import { AlertCircle, ChevronDown, RefreshCw } from 'lucide-react'; +import { AlertCircle, Check, ChevronDown, Copy, RefreshCw } from 'lucide-react'; import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState'; import CreatorListPagination from '@/components/common/CreatorListPagination'; import CreatorListGroupSeparator from '@/components/common/CreatorListGroupSeparator'; import MarketplaceSidebar from '@/components/common/MarketplaceSidebar'; +import { copyTextToClipboard } from '@/utils/clipboard.utils'; const FEATURED_CREATOR_FACTS = [ { label: 'Membership', value: 'Collectors Circle' }, @@ -81,6 +82,8 @@ const FEATURED_CREATOR_FACTS = [ const FEATURED_CREATOR_FOLLOWER_COUNT: number | null = null; const FEATURED_CREATOR_KEY_HOLDER_COUNT = 0; +const FEATURED_CREATOR_STELLAR_ADDRESS = + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'; // Fallback demo data in case API fails const DEMO_CREATORS: Course[] = [ @@ -210,6 +213,13 @@ const isCreatorRefreshShortcut = (event: KeyboardEvent) => !event.shiftKey && event.key.toLowerCase() === 'r'; +const isTradeShortcut = (event: KeyboardEvent) => + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === 't'; + const toPriceFilterValue = (value: string) => { if (!value.trim()) return undefined; const parsed = Number(value); @@ -294,6 +304,7 @@ function LandingPage() { const [tradeSide, setTradeSide] = useState('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); + const [stellarAddressCopied, setStellarAddressCopied] = useState(false); const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { const sort = searchParams.get('sort') as SortOption | null; @@ -713,9 +724,40 @@ function LandingPage() { displayedPortfolioValue ); - const openTradeDialog = (side: TradeSide) => { + const openTradeDialog = useCallback((side: TradeSide) => { setTradeSide(side); setTradeDialogOpen(true); + }, []); + + // Issue 554: T key opens the trade panel from the creator profile page. + useEffect(() => { + const handleTradeShortcut = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.repeat || + !isTradeShortcut(event) || + isEditableShortcutTarget(event.target) + ) { + return; + } + + event.preventDefault(); + openTradeDialog('buy'); + }; + + window.addEventListener('keydown', handleTradeShortcut); + return () => window.removeEventListener('keydown', handleTradeShortcut); + }, [openTradeDialog]); + + const handleCopyStellarAddress = async () => { + try { + await copyTextToClipboard(FEATURED_CREATOR_STELLAR_ADDRESS); + setStellarAddressCopied(true); + showToast.success('Address copied to clipboard', { duration: 2000 }); + setTimeout(() => setStellarAddressCopied(false), 2000); + } catch { + showToast.error('Could not copy the Stellar address. Please copy it manually.'); + } }; const handleConfirmTrade = async (amount: number) => { @@ -1390,6 +1432,42 @@ function LandingPage() { )} shares available` } /> + {/* Issue 557: Stellar address with copy button */} +
+
+

+ Stellar Address +

+

+ {FEATURED_CREATOR_STELLAR_ADDRESS} +

+
+ +
{isNetworkMismatch && }