Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 14 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,17 @@ dist-ssr
*.sln
*.sw?
issue.md
pr.md
pr.md

# Test artifacts
__snapshots__
*.snap
coverage
.nyc_output

# OS artifacts
Thumbs.db

# Lock files from other package managers
package-lock.json
yarn.lock
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
104 changes: 104 additions & 0 deletions src/hooks/__tests__/useFormatXlm.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
26 changes: 24 additions & 2 deletions src/hooks/useCreators.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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,
});
}
47 changes: 47 additions & 0 deletions src/hooks/useFormatXlm.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
82 changes: 80 additions & 2 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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[] = [
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -294,6 +304,7 @@ function LandingPage() {
const [tradeSide, setTradeSide] = useState<TradeSide>('buy');
const [tradeDialogOpen, setTradeDialogOpen] = useState(false);
const [tradeSubmitting, setTradeSubmitting] = useState(false);
const [stellarAddressCopied, setStellarAddressCopied] = useState(false);
const prefersReducedMotion = usePrefersReducedMotion();
const [sortOption, setSortOption] = useState<SortOption>(() => {
const sort = searchParams.get('sort') as SortOption | null;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -1390,6 +1432,42 @@ function LandingPage() {
)} shares available`
}
/>
{/* Issue 557: Stellar address with copy button */}
<div className="flex items-center justify-between gap-2 rounded-xl border border-white/8 bg-white/[0.03] px-3 py-2">
<div className="min-w-0 flex-1">
<p className="mb-0.5 text-[0.6rem] font-bold uppercase tracking-[0.2em] text-white/40">
Stellar Address
</p>
<p
className="truncate font-mono text-xs text-white/70"
title={FEATURED_CREATOR_STELLAR_ADDRESS}
>
{FEATURED_CREATOR_STELLAR_ADDRESS}
</p>
</div>
<button
type="button"
onClick={handleCopyStellarAddress}
aria-label={
stellarAddressCopied
? 'Stellar address copied'
: 'Copy Stellar address'
}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-white/5 text-white/40 transition-colors hover:bg-white/10 hover:text-white"
>
{stellarAddressCopied ? (
<Check
className="size-4 text-emerald-400"
aria-hidden="true"
/>
) : (
<Copy
className="size-4"
aria-hidden="true"
/>
)}
</button>
</div>
{isNetworkMismatch && <NetworkMismatchBanner />}
<div className="relative">
<div
Expand Down
Loading