diff --git a/src/components/common/ExportTradeHistoryCsvButton.tsx b/src/components/common/ExportTradeHistoryCsvButton.tsx new file mode 100644 index 00000000..ca5d617f --- /dev/null +++ b/src/components/common/ExportTradeHistoryCsvButton.tsx @@ -0,0 +1,106 @@ +import React, { useState } from 'react'; +import { Download, Loader2 } from 'lucide-react'; +import { useAccount } from 'wagmi'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { downloadCsv } from '@/utils/transactionHistory.utils'; +import { + buildTradeHistoryCsvFilename, + generateTradeHistoryCsv, +} from '@/utils/tradeLedger.utils'; +import { fetchAllTrades } from '@/hooks/useAllTradeHistory'; +import type { Trade } from '@/services/tradeHistory.service'; + +export interface ExportTradeHistoryCsvButtonProps { + /** Wallet address whose trades to export. */ + walletAddress: string; + /** Optional authenticated override (e.g. for testing). */ + isAuthenticated?: boolean; + /** Whether the viewer is viewing their own profile dashboard. */ + isOwnProfile?: boolean; + /** Optional custom trade fetcher (e.g. for testing). */ + fetchTrades?: (wallet: string) => Promise; + /** Custom class name. */ + className?: string; + /** Custom test ID. */ + 'data-testid'?: string; +} + +/** + * Trade history CSV export button on the portfolio page (#857). + * + * Placed in the trade history tab header. When clicked by an authenticated + * user, fetches all trades from GET /users/:wallet/trades without pagination + * limit, displays a loading spinner on the button, generates a formatted CSV + * (columns: Date, Key Name, Type, Quantity, Price per Key, Total, Fee), and + * triggers a file download with filename trades-[wallet-truncated]-[date].csv. + * Only accessible when the user is authenticated. + */ +export const ExportTradeHistoryCsvButton: React.FC< + ExportTradeHistoryCsvButtonProps +> = ({ + walletAddress, + isAuthenticated: customIsAuthenticated, + isOwnProfile = false, + fetchTrades = fetchAllTrades, + className, + 'data-testid': testId = 'export-trade-history-button', +}) => { + const { address: connectedAddress, isConnected } = useAccount(); + const [isLoading, setIsLoading] = useState(false); + + const isAuth = + customIsAuthenticated !== undefined + ? customIsAuthenticated + : Boolean(connectedAddress && (isConnected ?? true)) || + Boolean(isOwnProfile); + + // Button accessible only when the user is authenticated (#857) + if (!isAuth) { + return null; + } + + const handleExport = async () => { + if (isLoading || !walletAddress) return; + setIsLoading(true); + + try { + const trades = await fetchTrades(walletAddress); + const csv = generateTradeHistoryCsv(trades); + const filename = buildTradeHistoryCsvFilename(walletAddress); + downloadCsv(filename, csv); + } catch (error) { + console.error('Failed to export trade history CSV:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + + ); +}; + +export default ExportTradeHistoryCsvButton; diff --git a/src/components/common/HeldKeysGrid.tsx b/src/components/common/HeldKeysGrid.tsx index f802d4e0..8edc4e83 100644 --- a/src/components/common/HeldKeysGrid.tsx +++ b/src/components/common/HeldKeysGrid.tsx @@ -16,6 +16,9 @@ import { } from '@/utils/portfolioValue.utils'; import { formatNumber } from '@/utils/numberFormat.utils'; import { isKeyDeprecated } from '@/utils/keyDeprecation.utils'; +import { isSplitActive } from '@/utils/keySplit.utils'; +import { keySplitEventService } from '@/services/keySplitEvents.service'; +import SplitAppliedBadge from '@/components/common/SplitAppliedBadge'; import type { Course } from '@/services/course.service'; import { cn } from '@/lib/utils'; @@ -80,6 +83,11 @@ const HeldKeysGrid: React.FC = ({ const title = creator?.title ?? 'Creator key'; const quantity = position.quantity ?? 0; const isDeprecated = isKeyDeprecated(creator); + const hasSplitApplied = Boolean( + isSplitActive(creator?.recentSplit) || + isSplitActive(creator?.splitExecuted) || + keySplitEventService.getActiveSplit(position.creatorId) + ); const creatorHref = `/creator/${position.creatorId}`; return ( @@ -89,12 +97,17 @@ const HeldKeysGrid: React.FC = ({ className="flex flex-col rounded-2xl border border-white/10 bg-white/[0.03] p-4 transition-colors hover:border-white/20" >
- - {title} - +
+ + {title} + + {hasSplitApplied && ( + + )} +
{position.pending && ( void; + className?: string; +} + +/** + * Key split event notification banner (#1151). + * + * Appears for 48 hours after a SplitExecuted event on key detail and portfolio pages. + * Informs holders of their adjusted balance and the split ratio applied. + * Includes a Dismiss button that clears the banner for the session. + */ +export const KeySplitBanner: React.FC = ({ + keyId: explicitKeyId, + creator, + userAddress, + split: explicitSplit, + preSplitBalance: overridePreSplitBalance, + postSplitBalance: overridePostSplitBalance, + ratio: overrideRatio, + holdingsCount, + onDismiss, + className, +}) => { + const effectiveKeyId = explicitKeyId ?? creator?.id ?? explicitSplit?.keyId ?? ''; + const initialSplit = explicitSplit ?? creator?.recentSplit ?? creator?.splitExecuted ?? null; + + const { + split, + isActive, + isDismissed: hookDismissed, + ratioDisplay, + ratio: hookRatio, + preSplitBalance: hookPreSplitBalance, + postSplitBalance: hookPostSplitBalance, + dismiss: hookDismiss, + } = useKeySplit({ + keyId: effectiveKeyId, + userAddress, + initialSplit, + holdingQuantity: holdingsCount, + }); + + const [localDismissed, setLocalDismissed] = useState(false); + + const isDismissed = localDismissed || hookDismissed; + + if (!isActive || isDismissed) { + return null; + } + + const displayRatio = overrideRatio != null ? `${overrideRatio}:1` : ratioDisplay; + const numericRatio = overrideRatio ?? split?.ratio ?? hookRatio; + + const finalPreSplitBalance = overridePreSplitBalance ?? hookPreSplitBalance; + const finalPostSplitBalance = + overridePostSplitBalance ?? + (overridePreSplitBalance != null && numericRatio > 0 + ? overridePreSplitBalance * numericRatio + : hookPostSplitBalance); + + const handleDismiss = () => { + setLocalDismissed(true); + hookDismiss(); + onDismiss?.(); + }; + + const keyTitle = creator?.title ? ` for ${creator.title}` : ''; + + return ( + + ); +}; + +export const SplitNotificationBanner = KeySplitBanner; +export default KeySplitBanner; diff --git a/src/components/common/SplitAppliedBadge.tsx b/src/components/common/SplitAppliedBadge.tsx new file mode 100644 index 00000000..435079a0 --- /dev/null +++ b/src/components/common/SplitAppliedBadge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Scissors } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface SplitAppliedBadgeProps { + className?: string; + title?: string; +} + +/** + * Split Applied badge rendered on portfolio pages for keys that split in the last 48h (#1151). + */ +export const SplitAppliedBadge: React.FC = ({ + className, + title = 'Key split applied within the last 48 hours', +}) => { + return ( + + + ); +}; + +export default SplitAppliedBadge; diff --git a/src/components/common/__tests__/ExportTradeHistoryCsvButton.test.tsx b/src/components/common/__tests__/ExportTradeHistoryCsvButton.test.tsx new file mode 100644 index 00000000..945c46cf --- /dev/null +++ b/src/components/common/__tests__/ExportTradeHistoryCsvButton.test.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import ExportTradeHistoryCsvButton from '@/components/common/ExportTradeHistoryCsvButton'; +import type { Trade } from '@/services/tradeHistory.service'; + +const mockUseAccount = vi.fn(); +vi.mock('wagmi', () => ({ + useAccount: () => mockUseAccount(), +})); + +const mockDownloadCsv = vi.fn(); +vi.mock('@/utils/transactionHistory.utils', () => ({ + downloadCsv: (...args: unknown[]) => mockDownloadCsv(...args), +})); + +const mockFetchAllTrades = vi.fn(); +vi.mock('@/hooks/useAllTradeHistory', () => ({ + fetchAllTrades: (...args: unknown[]) => mockFetchAllTrades(...args), +})); + +const WALLET = 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; + +const sampleTrades: Trade[] = [ + { + id: 'trade-1', + keyName: 'Alpha Key', + tradeType: 'Buy', + quantity: 5, + pricePerKey: 2, + feeXlm: 0.1, + timestamp: Date.parse('2026-10-20T10:00:00.000Z'), + transactionHash: 'hash-1', + }, + { + id: 'trade-2', + keyName: 'Beta Key', + tradeType: 'Sell', + quantity: 2, + pricePerKey: 4, + feeXlm: 0.05, + timestamp: Date.parse('2026-10-21T15:30:00.000Z'), + transactionHash: 'hash-2', + }, +]; + +describe('ExportTradeHistoryCsvButton (#857)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAccount.mockReturnValue({ + address: WALLET, + isConnected: true, + }); + mockFetchAllTrades.mockResolvedValue(sampleTrades); + }); + + it('does not render when the user is not authenticated', () => { + mockUseAccount.mockReturnValue({ + address: undefined, + isConnected: false, + }); + + render(); + + expect( + screen.queryByRole('button', { name: /export csv/i }) + ).not.toBeInTheDocument(); + }); + + it('renders the Export CSV button when user is authenticated', () => { + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + + it('shows loading spinner and disables button while fetch is in progress', async () => { + let resolveFetch: (trades: Trade[]) => void = () => {}; + const pendingPromise = new Promise(resolve => { + resolveFetch = resolve; + }); + mockFetchAllTrades.mockReturnValue(pendingPromise); + + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + fireEvent.click(button); + + // Spinner should be visible and button disabled while fetching + expect( + screen.getByTestId('export-trade-history-spinner') + ).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + + // Resolve fetch + resolveFetch(sampleTrades); + + await waitFor(() => { + expect( + screen.queryByTestId('export-trade-history-spinner') + ).not.toBeInTheDocument(); + expect(button).not.toBeDisabled(); + }); + }); + + it('fetches all trades for the wallet and downloads CSV with correct columns and filename', async () => { + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(mockFetchAllTrades).toHaveBeenCalledWith(WALLET); + expect(mockDownloadCsv).toHaveBeenCalledTimes(1); + }); + + const [filename, csv] = mockDownloadCsv.mock.calls[0] as [string, string]; + + // Filename includes truncated wallet and current date + expect(filename).toMatch(/^trades-GDEM…0001-\d{4}-\d{2}-\d{2}\.csv$/); + + // CSV contains correct columns and data + const lines = csv.split('\r\n'); + expect(lines[0]).toBe( + 'Date,Key Name,Type,Quantity,Price per Key,Total,Fee' + ); + expect(lines[1]).toBe( + '2026-10-20T10:00:00.000Z,Alpha Key,Buy,5,2,10.1,0.1' + ); + expect(lines[2]).toBe( + '2026-10-21T15:30:00.000Z,Beta Key,Sell,2,4,7.95,0.05' + ); + }); + + it('handles fetch error gracefully and resets loading state', async () => { + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + mockFetchAllTrades.mockRejectedValue(new Error('Network error')); + + render(); + + const button = screen.getByRole('button', { name: /export csv/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(button).not.toBeDisabled(); + expect( + screen.queryByTestId('export-trade-history-spinner') + ).not.toBeInTheDocument(); + }); + + expect(mockDownloadCsv).not.toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); diff --git a/src/components/common/__tests__/KeySplitBanner.test.tsx b/src/components/common/__tests__/KeySplitBanner.test.tsx new file mode 100644 index 00000000..17736eac --- /dev/null +++ b/src/components/common/__tests__/KeySplitBanner.test.tsx @@ -0,0 +1,136 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import KeySplitBanner from '../KeySplitBanner'; +import type { SplitExecutedEvent } from '@/types/keySplit'; +import { clearSplitDismissal } from '@/utils/keySplit.utils'; +import { keySplitEventService } from '@/services/keySplitEvents.service'; + +function renderWithClient(ui: React.ReactElement) { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return render({ui}); +} + +describe('KeySplitBanner (#1151)', () => { + beforeEach(() => { + clearSplitDismissal(); + keySplitEventService.reset(); + vi.clearAllMocks(); + }); + + it('renders banner when split occurred within 48h with ratio, pre-split and post-split balances', () => { + const recentSplit: SplitExecutedEvent = { + keyId: 'creator-1', + ratio: 2, + executedAt: new Date(Date.now() - 3600 * 1000).toISOString(), // 1h ago + preSplitBalance: 10, + postSplitBalance: 20, + }; + + renderWithClient( + + ); + + expect(screen.getByTestId('key-split-banner')).toBeInTheDocument(); + expect(screen.getByTestId('split-ratio')).toHaveTextContent('2:1'); + expect(screen.getByTestId('pre-split-balance')).toHaveTextContent('10'); + expect(screen.getByTestId('post-split-balance')).toHaveTextContent('20'); + }); + + it('does not render banner when split is older than 48h', () => { + const expiredSplit: SplitExecutedEvent = { + keyId: 'creator-1', + ratio: 2, + executedAt: new Date(Date.now() - 50 * 3600 * 1000).toISOString(), // 50h ago + preSplitBalance: 10, + postSplitBalance: 20, + }; + + renderWithClient( + + ); + + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + }); + + it('dismiss button clears the banner for the session and calls onDismiss', async () => { + const user = userEvent.setup(); + const onDismiss = vi.fn(); + const recentSplit: SplitExecutedEvent = { + keyId: 'creator-1', + ratio: 3, + executedAt: new Date().toISOString(), + preSplitBalance: 5, + postSplitBalance: 15, + }; + + const { unmount } = renderWithClient( + + ); + + const banner = screen.getByTestId('key-split-banner'); + expect(banner).toBeInTheDocument(); + + const dismissBtn = screen.getByTestId('split-banner-dismiss'); + await user.click(dismissBtn); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + + // Re-rendering in the same session stays dismissed + unmount(); + renderWithClient( + + ); + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + }); + + it('displays creator title and explicit balance overrides', () => { + const recentSplit: SplitExecutedEvent = { + keyId: 'creator-1', + ratio: 2, + executedAt: new Date().toISOString(), + }; + + renderWithClient( + + ); + + expect(screen.getByTestId('key-split-banner')).toBeInTheDocument(); + expect(screen.getByText(/for Apex Key/i)).toBeInTheDocument(); + expect(screen.getByTestId('pre-split-balance')).toHaveTextContent('25'); + expect(screen.getByTestId('post-split-balance')).toHaveTextContent('50'); + }); +}); diff --git a/src/components/common/__tests__/SplitAppliedBadge.test.tsx b/src/components/common/__tests__/SplitAppliedBadge.test.tsx new file mode 100644 index 00000000..46c0f022 --- /dev/null +++ b/src/components/common/__tests__/SplitAppliedBadge.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import SplitAppliedBadge from '../SplitAppliedBadge'; + +describe('SplitAppliedBadge', () => { + it('renders Split Applied text and has data-testid="split-applied-badge"', () => { + render(); + + const badge = screen.getByTestId('split-applied-badge'); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent('Split Applied'); + }); + + it('applies custom className and title', () => { + render( + + ); + + const badge = screen.getByTestId('split-applied-badge'); + expect(badge).toHaveClass('custom-class'); + expect(badge).toHaveAttribute('title', 'Custom title tooltip'); + }); +}); diff --git a/src/hooks/__tests__/useKeySplit.test.tsx b/src/hooks/__tests__/useKeySplit.test.tsx new file mode 100644 index 00000000..dcbc6f51 --- /dev/null +++ b/src/hooks/__tests__/useKeySplit.test.tsx @@ -0,0 +1,163 @@ +import { renderHook, act } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React from 'react'; +import { useKeySplit, useActiveKeySplits } from '../useKeySplit'; +import { keySplitEventService } from '@/services/keySplitEvents.service'; +import { clearSplitDismissal } from '@/utils/keySplit.utils'; +import type { SplitExecutedEvent } from '@/types/keySplit'; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe('useKeySplit (#1151)', () => { + beforeEach(() => { + clearSplitDismissal(); + keySplitEventService.reset(); + vi.clearAllMocks(); + }); + + it('returns active status and calculated balances for split within 48h', () => { + const split: SplitExecutedEvent = { + keyId: 'alpha', + ratio: 2, + executedAt: new Date(Date.now() - 2 * 3600 * 1000).toISOString(), + preSplitBalance: 10, + postSplitBalance: 20, + }; + + const { result } = renderHook( + () => + useKeySplit({ + keyId: 'alpha', + initialSplit: split, + holdingQuantity: 20, + }), + { wrapper: createWrapper() } + ); + + expect(result.current.isActive).toBe(true); + expect(result.current.isDismissed).toBe(false); + expect(result.current.ratio).toBe(2); + expect(result.current.ratioDisplay).toBe('2:1'); + expect(result.current.preSplitBalance).toBe(10); + expect(result.current.postSplitBalance).toBe(20); + }); + + it('returns inactive for split older than 48h', () => { + const split: SplitExecutedEvent = { + keyId: 'beta', + ratio: 2, + executedAt: new Date(Date.now() - 50 * 3600 * 1000).toISOString(), + preSplitBalance: 5, + postSplitBalance: 10, + }; + + const { result } = renderHook( + () => + useKeySplit({ + keyId: 'beta', + initialSplit: split, + }), + { wrapper: createWrapper() } + ); + + expect(result.current.isActive).toBe(false); + }); + + it('dismiss() updates isDismissed to true', () => { + const split: SplitExecutedEvent = { + keyId: 'gamma', + ratio: 3, + executedAt: new Date().toISOString(), + preSplitBalance: 4, + postSplitBalance: 12, + }; + + const { result } = renderHook( + () => + useKeySplit({ + keyId: 'gamma', + initialSplit: split, + }), + { wrapper: createWrapper() } + ); + + expect(result.current.isDismissed).toBe(false); + + act(() => { + result.current.dismiss(); + }); + + expect(result.current.isDismissed).toBe(true); + }); + + it('updates immediately on incoming SplitExecuted realtime event', () => { + const { result } = renderHook( + () => + useKeySplit({ + keyId: 'live-key', + userAddress: '0xuser123', + holdingQuantity: 15, + }), + { wrapper: createWrapper() } + ); + + expect(result.current.isActive).toBe(false); + + const incomingEvent: SplitExecutedEvent = { + keyId: 'live-key', + ratio: 2, + executedAt: new Date().toISOString(), + preSplitBalance: 15, + postSplitBalance: 30, + }; + + act(() => { + keySplitEventService.emitSplitExecuted(incomingEvent); + }); + + expect(result.current.isActive).toBe(true); + expect(result.current.ratio).toBe(2); + expect(result.current.preSplitBalance).toBe(15); + expect(result.current.postSplitBalance).toBe(30); + }); +}); + +describe('useActiveKeySplits (#1151)', () => { + beforeEach(() => { + keySplitEventService.reset(); + }); + + it('identifies keys that underwent a split within 48h', () => { + const activeSplit: SplitExecutedEvent = { + keyId: 'active-key', + ratio: 2, + executedAt: new Date(Date.now() - 1000).toISOString(), + }; + const expiredSplit: SplitExecutedEvent = { + keyId: 'expired-key', + ratio: 2, + executedAt: new Date(Date.now() - 50 * 3600 * 1000).toISOString(), + }; + + keySplitEventService.recordSplit(activeSplit); + keySplitEventService.recordSplit(expiredSplit); + + const { result } = renderHook(() => useActiveKeySplits(), { + wrapper: createWrapper(), + }); + + expect(result.current.isKeySplitActive('active-key')).toBe(true); + expect(result.current.isKeySplitActive('expired-key')).toBe(false); + expect(result.current.isKeySplitActive('unknown-key')).toBe(false); + }); +}); diff --git a/src/hooks/useAllTradeHistory.ts b/src/hooks/useAllTradeHistory.ts index cde9eb8e..52219b5d 100644 --- a/src/hooks/useAllTradeHistory.ts +++ b/src/hooks/useAllTradeHistory.ts @@ -10,7 +10,7 @@ export const MAX_TRADE_HISTORY_PAGES = 200; /** * Loads every page of a wallet's trade history (following the cursor) so the - * portfolio table can filter, paginate and export all matching rows (#1114). + * portfolio table can filter, paginate and export all matching rows (#857, #1114). */ export async function fetchAllTrades(wallet: string): Promise { const trades: Trade[] = []; diff --git a/src/hooks/useKeySplit.ts b/src/hooks/useKeySplit.ts new file mode 100644 index 00000000..8310fc11 --- /dev/null +++ b/src/hooks/useKeySplit.ts @@ -0,0 +1,245 @@ +import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { QueryClientContext } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import type { SplitExecutedEvent } from '@/types/keySplit'; +import { + dismissSplitForSession, + formatSplitRatio, + getSplitRemainingMs, + isSplitActive, + isSplitDismissedForSession, + resolveSplitBalances, +} from '@/utils/keySplit.utils'; +import { keySplitEventService } from '@/services/keySplitEvents.service'; +import { useWalletHoldings } from './useWallet'; + +export function useSafeQueryClient() { + return useContext(QueryClientContext); +} + +export interface UseKeySplitOptions { + keyId?: string; + userAddress?: string; + /** Direct split event instance (e.g. from course.recentSplit). */ + initialSplit?: SplitExecutedEvent | null; + /** Quantity of keys held by the connected wallet. */ + holdingQuantity?: number | null; +} + +export interface UseKeySplitResult { + /** Active split event, or null if no split or split is expired (> 48h). */ + split: SplitExecutedEvent | null; + /** Whether a split occurred within the last 48 hours. */ + isActive: boolean; + /** Whether the banner was dismissed for this session. */ + isDismissed: boolean; + /** Formatted ratio string (e.g. "2:1"). */ + ratioDisplay: string; + /** Numeric split ratio. */ + ratio: number; + /** Pre-split holding balance for this wallet. */ + preSplitBalance: number; + /** Post-split holding balance for this wallet. */ + postSplitBalance: number; + /** Dismiss the split notification for this session. */ + dismiss: () => void; + /** Manually trigger balance refresh. */ + refetchBalances: () => Promise; +} + +/** + * Hook to manage split event lifecycle, live socket updates, balances, and session dismissal (#1151). + */ +export function useKeySplit({ + keyId, + userAddress, + initialSplit, + holdingQuantity: explicitHoldingQuantity, +}: UseKeySplitOptions = {}): UseKeySplitResult { + const queryClient = useSafeQueryClient(); + const [, setTick] = useState(0); + + // Fetch holdings if userAddress is provided and no explicit quantity was passed + const { data: holdings = [], refetch: refetchHoldings } = useWalletHoldings( + userAddress ?? '' + ); + + const holdingQuantity = useMemo(() => { + if (explicitHoldingQuantity != null) return explicitHoldingQuantity; + if (!keyId || !holdings.length) return 0; + const found = holdings.find(h => h.creatorId === keyId); + return found?.quantity ?? 0; + }, [explicitHoldingQuantity, holdings, keyId]); + + // Fetch recent split from API / cache if keyId is present + const [queriedSplit, setQueriedSplit] = useState(() => { + if (keyId) return keySplitEventService.getActiveSplit(keyId); + return null; + }); + + useEffect(() => { + if (!keyId) return; + let cancelled = false; + void keySplitEventService.fetchRecentSplit(keyId).then(res => { + if (!cancelled && res) { + setQueriedSplit(res); + } + }); + return () => { + cancelled = true; + }; + }, [keyId]); + + // Derive the candidate split from initial props, query result, or service cache + const candidateSplit = useMemo(() => { + if (initialSplit !== undefined) { + if (initialSplit && isSplitActive(initialSplit)) { + keySplitEventService.recordSplit(initialSplit); + return initialSplit; + } + return initialSplit; + } + if (queriedSplit && isSplitActive(queriedSplit)) { + return queriedSplit; + } + if (keyId) { + const cached = keySplitEventService.getActiveSplit(keyId); + if (cached && isSplitActive(cached)) return cached; + } + return queriedSplit ?? null; + }, [initialSplit, queriedSplit, keyId]); + + const [liveSplit, setLiveSplit] = useState(null); + + // Subscribe to realtime SplitExecuted events for this key + useEffect(() => { + if (!keyId) return; + + const unsubscribe = keySplitEventService.subscribe(keyId, event => { + setLiveSplit(event); + // Updated balances fetched and displayed immediately after split event (#1151) + if (userAddress) { + void queryClient?.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(userAddress), + }); + void refetchHoldings(); + } + void queryClient?.invalidateQueries({ + queryKey: queryKeys.creators.detail(keyId), + }); + void queryClient?.invalidateQueries({ + queryKey: queryKeys.creators.all, + }); + }); + + return () => { + unsubscribe(); + }; + }, [keyId, userAddress, queryClient, refetchHoldings]); + + const activeSplit = liveSplit ?? candidateSplit; + const isActive = Boolean(activeSplit && isSplitActive(activeSplit)); + + // Dismissal state backed by sessionStorage + const [sessionDismissed, setSessionDismissed] = useState(() => { + if (!keyId || !activeSplit) return false; + return isSplitDismissedForSession(keyId, activeSplit.executedAt); + }); + + // Update dismissal state if keyId or activeSplit changes + useEffect(() => { + if (keyId && activeSplit) { + setSessionDismissed(isSplitDismissedForSession(keyId, activeSplit.executedAt)); + } + }, [keyId, activeSplit]); + + // Auto-expire when the 48-hour window elapses while mounted + useEffect(() => { + if (!activeSplit || !isActive) return; + + const remainingMs = getSplitRemainingMs(activeSplit); + if (remainingMs <= 0) return; + + const timer = window.setTimeout(() => { + setTick(t => t + 1); + }, remainingMs); + + return () => { + window.clearTimeout(timer); + }; + }, [activeSplit, isActive]); + + const dismiss = useCallback(() => { + if (!keyId) return; + setSessionDismissed(true); + dismissSplitForSession(keyId, activeSplit?.executedAt); + }, [keyId, activeSplit]); + + const refetchBalances = useCallback(async () => { + if (userAddress) { + await queryClient?.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(userAddress), + }); + await refetchHoldings(); + } + }, [userAddress, queryClient, refetchHoldings]); + + const balances = useMemo(() => { + if (!activeSplit) { + return { preSplitBalance: 0, postSplitBalance: 0 }; + } + return resolveSplitBalances(activeSplit, holdingQuantity, true); + }, [activeSplit, holdingQuantity]); + + const ratio = activeSplit?.ratio ?? 1; + + return { + split: activeSplit, + isActive, + isDismissed: sessionDismissed, + ratioDisplay: formatSplitRatio(ratio), + ratio, + preSplitBalance: balances.preSplitBalance, + postSplitBalance: balances.postSplitBalance, + dismiss, + refetchBalances, + }; +} + +/** + * Hook to retrieve all active key splits (for portfolio page highlighting #1151). + */ +export function useActiveKeySplits(userAddress?: string) { + const queryClient = useSafeQueryClient(); + const [, setTick] = useState(0); + + // Subscribe to all split events to update badges dynamically + useEffect(() => { + const unsubscribe = keySplitEventService.subscribe('*', () => { + setTick(t => t + 1); + if (userAddress && queryClient) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(userAddress), + }); + } + }); + + return () => { + unsubscribe(); + }; + }, [userAddress, queryClient]); + + const activeSplits = useMemo(() => { + return keySplitEventService.getAllActiveSplits(); + }, []); + + const isKeySplitActive = useCallback((keyId: string): boolean => { + const split = keySplitEventService.getActiveSplit(keyId); + return Boolean(split && isSplitActive(split)); + }, []); + + return { + activeSplits, + isKeySplitActive, + }; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index f5809f4b..d16c9028 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -63,6 +63,8 @@ export const queryKeys = { ['creators', creatorId, 'buyback'] as const, keyConfig: (creatorId: string) => ['creators', creatorId, 'key-config'] as const, + split: (creatorId: string) => + ['creators', creatorId, 'split'] as const, vesting: (creatorId: string) => ['creators', creatorId, 'vesting'] as const, vestingClaims: (creatorId: string, wallet: string) => diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index 011008e8..1b7ee247 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -99,6 +99,7 @@ import GraduatedCurveMilestoneChart from '@/components/common/GraduatedCurveMile import KeyDeprecationBanner from '@/components/common/KeyDeprecationBanner'; import KeyTradingPausedBanner from '@/components/common/KeyTradingPausedBanner'; import KeyFreezeStatusBanner from '@/components/common/KeyFreezeStatusBanner'; +import KeySplitBanner from '@/components/common/KeySplitBanner'; import AddToBundleButton from '@/components/common/AddToBundleButton'; import AddToBatchButton from '@/components/common/AddToBatchButton'; import MergeProposalBanner from '@/components/common/MergeProposalBanner'; @@ -638,6 +639,13 @@ function CreatorDetailPageContent() { + +
-
-

- Trade History -

-

- A full audit trail of past buys and sells -

+
+
+

+ Trade History +

+

+ A full audit trail of past buys and sells +

+
+
{' '} @@ -577,17 +584,19 @@ export default function ProfilePage() { {/* Price alerts panel (#1057) */} {activeTab === 'price-alerts' && ( -
-
-

Price Alerts

- -
-
+
+
+

+ Price Alerts +

+ +
+
)} {/* Activity feed panel */} diff --git a/src/pages/WalletPortfolioPage.tsx b/src/pages/WalletPortfolioPage.tsx index 0ccbb5ce..494ab89c 100644 --- a/src/pages/WalletPortfolioPage.tsx +++ b/src/pages/WalletPortfolioPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Link, useNavigate } from 'react-router'; import { useAccount } from 'wagmi'; import { BarChart3, Wallet, Layers } from 'lucide-react'; @@ -13,6 +13,10 @@ import { useWalletHoldings } from '@/hooks/useWallet'; import { useCreatorPrices } from '@/hooks/useCreatorPrices'; import { cn } from '@/lib/utils'; import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import { useActiveKeySplits } from '@/hooks/useKeySplit'; +import { isSplitActive } from '@/utils/keySplit.utils'; +import KeySplitBanner from '@/components/common/KeySplitBanner'; +import SplitAppliedBadge from '@/components/common/SplitAppliedBadge'; import { calculatePnLSummary, calculatePortfolioValue, @@ -141,6 +145,30 @@ export default function WalletPortfolioPage() { const isLoading = holdingsQuery.isLoading || areCreatorsLoading; + const { isKeySplitActive: isKeySplitActiveFromHook } = + useActiveKeySplits(address); + + const checkKeySplitActive = useCallback( + (creatorId: string) => { + if (isKeySplitActiveFromHook(creatorId)) return true; + const creator = creators.find(c => c.id === creatorId); + return Boolean( + isSplitActive(creator?.recentSplit) || + isSplitActive(creator?.splitExecuted) + ); + }, + [creators, isKeySplitActiveFromHook] + ); + + const splitHolding = useMemo(() => { + return heldPositions.find(p => checkKeySplitActive(p.creatorId)); + }, [heldPositions, checkKeySplitActive]); + + const splitCreator = useMemo(() => { + if (!splitHolding) return undefined; + return creators.find(c => c.id === splitHolding.creatorId); + }, [splitHolding, creators]); + const handleSort = (key: PortfolioTableSortKey) => { if (key === sortKey) { setSortDirection(previous => (previous === 'asc' ? 'desc' : 'asc')); @@ -203,6 +231,15 @@ export default function WalletPortfolioPage() {
+ {splitHolding && ( + + )} + {/* Summary cards (#1116) */}
- event.stopPropagation()} - > - {row.keyName} - +
+ event.stopPropagation()} + > + {row.keyName} + + {checkKeySplitActive(row.creatorId) && ( + + )} +
{row.pnl.quantity} diff --git a/src/pages/__tests__/CreatorDetailPage.keySplit.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.keySplit.integration.test.tsx new file mode 100644 index 00000000..b954d718 --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.keySplit.integration.test.tsx @@ -0,0 +1,217 @@ +/** + * Integration tests for the key split banner on CreatorDetailPage (#1151). + */ +import { render, screen, waitFor, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import CreatorDetailPage from '../CreatorDetailPage'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import { useWalletHoldings } from '@/hooks/useWallet'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { keySplitEventService } from '@/services/keySplitEvents.service'; +import { clearSplitDismissal } from '@/utils/keySplit.utils'; +import type { SplitExecutedEvent } from '@/types/keySplit'; + +vi.mock('@/hooks/useCreators', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +vi.mock('@/hooks/useWallet', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + useWalletHoldings: vi.fn(), + useTradeMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ + useCreatorProfileStaleIndicator: () => ({ + shouldShowBadge: false, + handleRefetch: vi.fn(), + }), +})); + +vi.mock('@/hooks/useNavigationTiming', () => ({ + useNavigationTiming: vi.fn(), +})); + +vi.mock('@/hooks/usePurchaseConfetti', () => ({ + usePurchaseConfetti: vi.fn(), +})); + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + +const renderPage = (client = createTestQueryClient()) => + render( + + + + } /> + + + + ); + +describe('CreatorDetailPage Key Split Event Notification (#1151)', () => { + const baseCreator = { + id: 'creator-1', + title: 'Alex Rivers', + description: 'Digital Artist & Illustrator', + price: 0.1, + priceStroops: 1_000_000, + instructorId: 'inst-1', + socialHandle: 'alexrivers', + isVerified: true, + thumbnail: 'https://example.com/avatar.jpg', + category: 'Art', + level: 'BEGINNER' as const, + creatorFeeBps: 0, + protocolFeeBps: 0, + creatorShareSupply: 50, + volume24h: 10_000_000, + }; + + const mockCreator = (creator: Record) => + vi.mocked(useCreatorDetail).mockReturnValue({ + data: creator, + isLoading: false, + error: null, + isFetching: false, + refetch: vi.fn(), + } as unknown as ReturnType); + + beforeEach(() => { + clearSplitDismissal(); + keySplitEventService.reset(); + vi.clearAllMocks(); + + vi.mocked(useWalletHoldings).mockReturnValue({ + data: [ + { + creatorId: 'creator-1', + quantity: 20, + priceStroops: 1_000_000, + }, + ], + isLoading: false, + refetch: vi.fn(), + } as unknown as ReturnType); + }); + + it('renders split banner for 48h after SplitExecuted event with ratio, pre-split, and post-split balances', async () => { + const recentSplit: SplitExecutedEvent = { + keyId: 'creator-1', + ratio: 2, + executedAt: new Date(Date.now() - 2 * 3600 * 1000).toISOString(), // 2h ago + preSplitBalance: 10, + postSplitBalance: 20, + }; + + mockCreator({ + ...baseCreator, + recentSplit, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('key-split-banner')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('split-ratio')).toHaveTextContent('2:1'); + expect(screen.getByTestId('pre-split-balance')).toHaveTextContent('10'); + expect(screen.getByTestId('post-split-balance')).toHaveTextContent('20'); + }); + + it('does not render banner when split is older than 48h', async () => { + const expiredSplit: SplitExecutedEvent = { + keyId: 'creator-1', + ratio: 2, + executedAt: new Date(Date.now() - 50 * 3600 * 1000).toISOString(), // 50h ago + preSplitBalance: 10, + postSplitBalance: 20, + }; + + mockCreator({ + ...baseCreator, + recentSplit: expiredSplit, + }); + + renderPage(); + + await waitFor(() => { + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + }); + }); + + it('dismiss button clears the banner for the session', async () => { + const user = userEvent.setup(); + const recentSplit: SplitExecutedEvent = { + keyId: 'creator-1', + ratio: 2, + executedAt: new Date(Date.now() - 1000).toISOString(), + preSplitBalance: 10, + postSplitBalance: 20, + }; + + mockCreator({ + ...baseCreator, + recentSplit, + }); + + const { unmount } = renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('key-split-banner')).toBeInTheDocument(); + }); + + const dismissBtn = screen.getByTestId('split-banner-dismiss'); + await user.click(dismissBtn); + + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + + // Re-mount stays dismissed for the session + unmount(); + renderPage(); + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + }); + + it('updates balance and displays banner immediately upon receiving a realtime SplitExecuted event', async () => { + mockCreator(baseCreator); + + renderPage(); + + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + + // Incoming live split event + act(() => { + keySplitEventService.emitSplitExecuted({ + keyId: 'creator-1', + ratio: 3, + executedAt: new Date().toISOString(), + preSplitBalance: 20, + postSplitBalance: 60, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('key-split-banner')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('split-ratio')).toHaveTextContent('3:1'); + expect(screen.getByTestId('pre-split-balance')).toHaveTextContent('20'); + expect(screen.getByTestId('post-split-balance')).toHaveTextContent('60'); + }); +}); diff --git a/src/pages/__tests__/ProfilePage.integration.test.tsx b/src/pages/__tests__/ProfilePage.integration.test.tsx index 5813b4e1..bb56c4a3 100644 --- a/src/pages/__tests__/ProfilePage.integration.test.tsx +++ b/src/pages/__tests__/ProfilePage.integration.test.tsx @@ -25,6 +25,11 @@ const { vi.mock('@/hooks/useWallet', () => ({ useWalletHoldings: (...args: unknown[]) => mockUseWalletHoldings(...args), useTradeHistory: (...args: unknown[]) => mockUseTradeHistory(...args), + useWalletBadges: () => ({ + data: [], + isLoading: false, + isError: false, + }), useClaimStakeMutation: () => ({ mutate: vi.fn(), isPending: false, @@ -34,6 +39,7 @@ vi.mock('@/hooks/useWallet', () => ({ vi.mock('@/hooks/useAllTradeHistory', () => ({ useAllTradeHistory: (...args: unknown[]) => mockUseAllTradeHistory(...args), + fetchAllTrades: vi.fn().mockResolvedValue([]), })); vi.mock('@/hooks/useCreatorPrices', () => ({ @@ -272,7 +278,7 @@ describe('ProfilePage — user profile page (#921)', () => { }); }); - it('shows the paginated trade history table with CSV export on the trade tab (#1114)', () => { + it('shows the paginated trade history table with CSV export on the trade tab (#1114, #857)', () => { seedOwnerView(); renderProfile('/profile?tab=trade-history'); @@ -281,6 +287,37 @@ describe('ProfilePage — user profile page (#921)', () => { expect(screen.getByTestId('trade-ledger-table')).toBeInTheDocument(); expect(screen.getByText('Alpha Key')).toBeInTheDocument(); expect(screen.getByTestId('trade-ledger-export')).toBeInTheDocument(); + expect( + screen.getByTestId('export-trade-history-button') + ).toBeInTheDocument(); + }); + + it('hides the header CSV export button when unauthenticated (#857)', () => { + mockUseAccount.mockReturnValue({ + address: undefined, + isConnected: false, + }); + mockUseWalletHoldings.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }); + mockUseCreatorPrices.mockReturnValue({ data: [], isLoading: false }); + mockUseStakingPositions.mockReturnValue({ + data: { positions: [] }, + isLoading: false, + }); + mockUseAllTradeHistory.mockReturnValue({ + data: [], + isLoading: false, + isError: false, + }); + + renderProfile(`/profile/${DEMO_WALLET}?tab=trade-history`); + + expect( + screen.queryByTestId('export-trade-history-button') + ).not.toBeInTheDocument(); }); it('renders staking positions with lock status and claimable rewards on the staking tab', () => { diff --git a/src/pages/__tests__/WalletPortfolioPage.keySplit.integration.test.tsx b/src/pages/__tests__/WalletPortfolioPage.keySplit.integration.test.tsx new file mode 100644 index 00000000..7b9abc66 --- /dev/null +++ b/src/pages/__tests__/WalletPortfolioPage.keySplit.integration.test.tsx @@ -0,0 +1,193 @@ +/** + * Integration tests for the Key Split 'Split Applied' badge and banner + * on WalletPortfolioPage (#1151). + */ +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import WalletPortfolioPage from '../WalletPortfolioPage'; +import { clearSplitDismissal } from '@/utils/keySplit.utils'; +import { keySplitEventService } from '@/services/keySplitEvents.service'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; +import type { Course } from '@/services/course.service'; +import type { SplitExecutedEvent } from '@/types/keySplit'; + +const mockUseAccount = vi.fn(); +vi.mock('wagmi', () => ({ + useAccount: (...args: unknown[]) => mockUseAccount(...args), +})); + +const mockUseWalletHoldings = vi.fn(); +vi.mock('@/hooks/useWallet', () => ({ + useWalletHoldings: (...args: unknown[]) => mockUseWalletHoldings(...args), +})); + +vi.mock('@/components/common/BadgeProgressTracker', () => ({ + default: () => null, +})); + +const mockUseCreatorPrices = vi.fn(); +vi.mock('@/hooks/useCreatorPrices', () => ({ + useCreatorPrices: (...args: unknown[]) => mockUseCreatorPrices(...args), +})); + +const WALLET = 'GPORTFOLIOTESTWALLET'; + +const RECENT_SPLIT: SplitExecutedEvent = { + keyId: 'alpha', + ratio: 2, + executedAt: new Date(Date.now() - 3600 * 1000).toISOString(), // 1h ago + preSplitBalance: 10, + postSplitBalance: 20, +}; + +const EXPIRED_SPLIT: SplitExecutedEvent = { + keyId: 'beta', + ratio: 2, + executedAt: new Date(Date.now() - 50 * 3600 * 1000).toISOString(), // 50h ago + preSplitBalance: 5, + postSplitBalance: 10, +}; + +const HOLDINGS: HeldKeyPosition[] = [ + { + creatorId: 'alpha', + quantity: 20, + averagePurchasePriceStroops: 1_000_000, + priceStroops: 2_000_000, + }, + { + creatorId: 'beta', + quantity: 10, + averagePurchasePriceStroops: 2_000_000, + priceStroops: 1_000_000, + }, +]; + +const CREATORS: Course[] = [ + { + id: 'alpha', + title: 'Alpha Key', + description: '', + instructorId: 'creator-a', + category: 'Art', + level: 'BEGINNER', + priceStroops: 2_000_000, + recentSplit: RECENT_SPLIT, + }, + { + id: 'beta', + title: 'Beta Key', + description: '', + instructorId: 'creator-b', + category: 'Music', + level: 'BEGINNER', + priceStroops: 1_000_000, + recentSplit: EXPIRED_SPLIT, + }, +]; + +function mockWallet({ + address = WALLET, + holdings = HOLDINGS, + creators = CREATORS, + isLoading = false, +}: { + address?: string | null; + holdings?: HeldKeyPosition[]; + creators?: Course[]; + isLoading?: boolean; +} = {}) { + mockUseAccount.mockReturnValue({ address }); + mockUseWalletHoldings.mockReturnValue({ data: holdings, isLoading, refetch: vi.fn() }); + mockUseCreatorPrices.mockReturnValue({ data: creators, isLoading }); +} + +function renderPage() { + return render( + + + } /> + + + ); +} + +describe('WalletPortfolioPage Key Split Badge & Banner (#1151)', () => { + beforeEach(() => { + clearSplitDismissal(); + keySplitEventService.reset(); + vi.clearAllMocks(); + mockWallet(); + }); + + it('renders "Split Applied" badge on split keys within 48h and omits for expired splits', async () => { + renderPage(); + + await waitFor(() => { + expect( + screen.getByTestId('portfolio-positions-table') + ).toBeInTheDocument(); + }); + + const rows = screen.getAllByTestId('portfolio-position-row'); + expect(rows).toHaveLength(2); + + // Alpha key has active split (< 48h) -> should have Split Applied badge + const alphaRow = rows[0]; + expect( + within(alphaRow).getByTestId('split-applied-badge') + ).toBeInTheDocument(); + expect( + within(alphaRow).getByTestId('split-applied-badge') + ).toHaveTextContent('Split Applied'); + + // Beta key has expired split (> 48h) -> should NOT have Split Applied badge + const betaRow = rows[1]; + expect( + within(betaRow).queryByTestId('split-applied-badge') + ).not.toBeInTheDocument(); + }); + + it('displays split notification banner on portfolio page with ratio, pre-split, and post-split balances', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('key-split-banner')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('split-ratio')).toHaveTextContent('2:1'); + expect(screen.getByTestId('pre-split-balance')).toHaveTextContent('10'); + expect(screen.getByTestId('post-split-balance')).toHaveTextContent('20'); + }); + + it('dismisses split banner for session on portfolio page while keeping badge visible', async () => { + const user = userEvent.setup(); + const { unmount } = renderPage(); + + await waitFor(() => { + expect(screen.getByTestId('key-split-banner')).toBeInTheDocument(); + }); + + const dismissBtn = screen.getByTestId('split-banner-dismiss'); + await user.click(dismissBtn); + + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + + // Badge remains visible on the row + const rows = screen.getAllByTestId('portfolio-position-row'); + expect( + within(rows[0]).getByTestId('split-applied-badge') + ).toBeInTheDocument(); + + // Remains dismissed in same session upon re-mount + unmount(); + renderPage(); + + expect(screen.queryByTestId('key-split-banner')).not.toBeInTheDocument(); + expect( + within(screen.getAllByTestId('portfolio-position-row')[0]).getByTestId('split-applied-badge') + ).toBeInTheDocument(); + }); +}); diff --git a/src/pages/__tests__/WalletPortfolioPage.test.tsx b/src/pages/__tests__/WalletPortfolioPage.test.tsx index 6e55492e..778df460 100644 --- a/src/pages/__tests__/WalletPortfolioPage.test.tsx +++ b/src/pages/__tests__/WalletPortfolioPage.test.tsx @@ -126,7 +126,7 @@ describe('WalletPortfolioPage (#1116)', () => { ); expect( screen.getByTestId('portfolio-total-pnl-percent') - ).toHaveTextContent('+25%'); + ).toHaveTextContent('+25'); }); it('renders one row per holding with per-position PnL', async () => { @@ -142,13 +142,13 @@ describe('WalletPortfolioPage (#1116)', () => { ).toHaveAttribute('href', '/creator/alpha'); expect( within(rows[0]).getByTestId('portfolio-row-pnl') - ).toHaveTextContent('+0.10 XLM'); + ).toHaveTextContent('+1.00 XLM'); expect( within(rows[0]).getByTestId('portfolio-row-pnl-percent') - ).toHaveTextContent('+100%'); + ).toHaveTextContent('+100'); expect( within(rows[1]).getByTestId('portfolio-row-pnl-percent') - ).toHaveTextContent('-50%'); + ).toHaveTextContent('-50'); }); it('re-sorts the table when a sortable header is clicked', async () => { diff --git a/src/services/course.service.ts b/src/services/course.service.ts index ede6460b..0864c6c4 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -1,6 +1,7 @@ // src/services/course.service.ts import { BaseApiService, ApiError, type APIResponse } from './api.service'; import type { CreatorSocialLinks } from '@/types/creatorProfile'; +import type { KeySplitEvent } from '@/types/keySplit'; import type { ContractDynamicFeeRate } from '@/utils/dynamicFeeRate.utils'; import { cacheManager } from '@/utils/cache.utils'; @@ -168,6 +169,10 @@ export interface Course { earlyAccessWhitelist?: string[]; /** ISO timestamp for when public purchases begin. */ publicLaunchDate?: string | null; + /** Recent key split event executed on this key (#1151). */ + recentSplit?: KeySplitEvent | null; + /** Alias for recentSplit (#1151). */ + splitExecuted?: KeySplitEvent | null; } export interface KeyDeprecation { diff --git a/src/services/keySplitEvents.service.ts b/src/services/keySplitEvents.service.ts new file mode 100644 index 00000000..5e3d5bdc --- /dev/null +++ b/src/services/keySplitEvents.service.ts @@ -0,0 +1,247 @@ +import { env } from '@/utils/env.utils'; +import type { SplitExecutedEvent } from '@/types/keySplit'; +import { isSplitActive, parseTimestampMs } from '@/utils/keySplit.utils'; +import { BaseApiService } from './api.service'; + +export type SplitExecutedHandler = (event: SplitExecutedEvent) => void; + +type UnknownRecord = Record; + +function asRecord(value: unknown): UnknownRecord { + return typeof value === 'object' && value !== null + ? (value as UnknownRecord) + : {}; +} + +function toFiniteNumber(value: unknown): number | null { + if (value == null || value === '') return null; + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Normalizes an incoming raw WebSocket frame or API response into a SplitExecutedEvent. + */ +export function normalizeSplitExecutedEvent(value: unknown): SplitExecutedEvent | null { + const envelope = asRecord(value); + const raw = asRecord(envelope.data ?? envelope); + + const eventType = String(envelope.type ?? raw.type ?? raw.event ?? ''); + // Accept explicit SplitExecuted events or flat payloads + if (eventType && !/SplitExecuted/i.test(eventType) && !raw.ratio) { + return null; + } + + const keyId = + raw.keyId ?? raw.key_id ?? raw.creatorId ?? raw.creator_id ?? raw.id; + const ratio = toFiniteNumber(raw.ratio ?? raw.splitRatio ?? raw.split_ratio); + + if (typeof keyId !== 'string' || keyId.trim() === '' || ratio == null || ratio <= 0) { + return null; + } + + const rawTimestamp = + raw.executedAt ?? + raw.executed_at ?? + raw.timestamp ?? + raw.createdAt ?? + raw.created_at; + + const executedAtMs = parseTimestampMs(rawTimestamp as string | number); + const executedAt = Number.isNaN(executedAtMs) + ? new Date().toISOString() + : typeof rawTimestamp === 'string' + ? rawTimestamp + : new Date(executedAtMs).toISOString(); + + const preSplitBalance = toFiniteNumber( + raw.preSplitBalance ?? raw.pre_split_balance ?? raw.previousBalance + ) ?? undefined; + + const postSplitBalance = toFiniteNumber( + raw.postSplitBalance ?? raw.post_split_balance ?? raw.newBalance + ) ?? undefined; + + const txHash = typeof raw.txHash === 'string' ? raw.txHash : undefined; + + return { + keyId: keyId.trim(), + ratio, + executedAt, + preSplitBalance, + postSplitBalance, + txHash, + }; +} + +/** + * In-memory registry and pub-sub manager for key split events (#1151). + */ +export class KeySplitEventService extends BaseApiService { + private readonly keyListeners = new Map>(); + private readonly globalListeners = new Set(); + private readonly activeSplits = new Map(); + private socket: WebSocket | null = null; + + constructor() { + super(); + this.initWebSocket(); + } + + private initWebSocket(): void { + if ( + typeof window === 'undefined' || + typeof WebSocket === 'undefined' || + !env.VITE_WEBSOCKET_URL + ) { + return; + } + + try { + this.socket = new WebSocket(env.VITE_WEBSOCKET_URL); + this.socket.onmessage = event => { + try { + const data = JSON.parse(event.data); + const normalized = normalizeSplitExecutedEvent(data); + if (normalized) { + this.emitSplitExecuted(normalized); + } + } catch { + // Ignore invalid frames + } + }; + } catch { + // WebSocket not available in this environment + } + } + + /** + * Emits a SplitExecuted event locally and notifies all subscribers. + */ + emitSplitExecuted(event: SplitExecutedEvent): void { + // Cache if within 48h active window + if (isSplitActive(event)) { + this.activeSplits.set(event.keyId, event); + } + + // Notify key-specific listeners + const listeners = this.keyListeners.get(event.keyId); + if (listeners) { + listeners.forEach(handler => { + try { + handler(event); + } catch (err) { + console.error('Error in split event handler:', err); + } + }); + } + + // Notify global listeners + this.globalListeners.forEach(handler => { + try { + handler(event); + } catch (err) { + console.error('Error in global split event handler:', err); + } + }); + } + + /** + * Subscribes to split events for a specific key (or '*' for all keys). + */ + subscribe( + keyId: string, + handler: SplitExecutedHandler + ): () => void { + if (keyId === '*') { + this.globalListeners.add(handler); + return () => { + this.globalListeners.delete(handler); + }; + } + + let listeners = this.keyListeners.get(keyId); + if (!listeners) { + listeners = new Set(); + this.keyListeners.set(keyId, listeners); + } + listeners.add(handler); + + return () => { + const current = this.keyListeners.get(keyId); + if (current) { + current.delete(handler); + if (current.size === 0) { + this.keyListeners.delete(keyId); + } + } + }; + } + + /** + * Records a known split event manually (e.g. from an API response or test). + */ + recordSplit(split: SplitExecutedEvent): void { + if (isSplitActive(split)) { + this.activeSplits.set(split.keyId, split); + } + } + + /** + * Gets the active split event for a creator key, or null if expired/none. + */ + getActiveSplit(keyId: string): SplitExecutedEvent | null { + const split = this.activeSplits.get(keyId); + if (split && isSplitActive(split)) { + return split; + } + if (split && !isSplitActive(split)) { + this.activeSplits.delete(keyId); + } + return null; + } + + /** + * Gets all active split events across all tracked creator keys. + */ + getAllActiveSplits(): SplitExecutedEvent[] { + const results: SplitExecutedEvent[] = []; + const now = Date.now(); + for (const [keyId, split] of this.activeSplits.entries()) { + if (isSplitActive(split, now)) { + results.push(split); + } else { + this.activeSplits.delete(keyId); + } + } + return results; + } + + /** + * Fetches the latest split for a key via the API if available. + */ + async fetchRecentSplit(keyId: string): Promise { + try { + const res = await this.api.get(`/keys/${keyId}/split`); + const normalized = normalizeSplitExecutedEvent(res.data?.data ?? res.data); + if (normalized && isSplitActive(normalized)) { + this.recordSplit(normalized); + return normalized; + } + return null; + } catch { + return this.getActiveSplit(keyId); + } + } + + /** + * Clears tracked splits and listeners for testing. + */ + reset(): void { + this.keyListeners.clear(); + this.globalListeners.clear(); + this.activeSplits.clear(); + } +} + +export const keySplitEventService = new KeySplitEventService(); diff --git a/src/types/keySplit.ts b/src/types/keySplit.ts new file mode 100644 index 00000000..f792eb7e --- /dev/null +++ b/src/types/keySplit.ts @@ -0,0 +1,20 @@ +/** + * Key split types and interfaces (#1151). + */ + +export interface SplitExecutedEvent { + /** Identifier of the creator key that underwent the split. */ + keyId: string; + /** Split ratio applied (e.g. 2 for 2:1, 3 for 3:1). */ + ratio: number; + /** Timestamp when the split was executed (ISO string or epoch ms). */ + executedAt: string | number; + /** Pre-split holding balance for the holder, if known. */ + preSplitBalance?: number; + /** Post-split holding balance for the holder, if known. */ + postSplitBalance?: number; + /** Optional transaction hash for on-chain execution. */ + txHash?: string; +} + +export type KeySplitEvent = SplitExecutedEvent; diff --git a/src/utils/__tests__/keySplit.utils.test.ts b/src/utils/__tests__/keySplit.utils.test.ts new file mode 100644 index 00000000..f837a8e2 --- /dev/null +++ b/src/utils/__tests__/keySplit.utils.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { + formatSplitRatio, + isSplitActive, + getSplitRemainingMs, + parseTimestampMs, + resolveSplitBalances, + isSplitDismissedForSession, + dismissSplitForSession, + clearSplitDismissal, + SPLIT_ACTIVE_DURATION_MS, +} from '../keySplit.utils'; +import type { SplitExecutedEvent } from '@/types/keySplit'; + +describe('keySplit.utils', () => { + beforeEach(() => { + if (typeof window !== 'undefined' && window.sessionStorage) { + window.sessionStorage.clear(); + } + }); + + describe('parseTimestampMs', () => { + it('parses ISO date string correctly', () => { + const iso = '2026-10-22T04:00:00.000Z'; + expect(parseTimestampMs(iso)).toBe(Date.parse(iso)); + }); + + it('parses numeric epoch milliseconds', () => { + const ms = 1770000000000; + expect(parseTimestampMs(ms)).toBe(ms); + }); + + it('converts unix seconds to milliseconds', () => { + const seconds = 1770000000; + expect(parseTimestampMs(seconds)).toBe(seconds * 1000); + }); + + it('returns NaN for invalid timestamp', () => { + expect(parseTimestampMs(undefined)).toBeNaN(); + expect(parseTimestampMs(null)).toBeNaN(); + expect(parseTimestampMs('')).toBeNaN(); + expect(parseTimestampMs('not-a-date')).toBeNaN(); + }); + }); + + describe('isSplitActive', () => { + const now = 1770000000000; + + it('returns true when split occurred 1 hour ago (< 48h)', () => { + const executedAt = now - 1 * 60 * 60 * 1000; + expect(isSplitActive({ executedAt }, now)).toBe(true); + }); + + it('returns true when split occurred 47 hours ago (< 48h)', () => { + const executedAt = now - 47 * 60 * 60 * 1000; + expect(isSplitActive({ executedAt }, now)).toBe(true); + }); + + it('returns true when split occurred exactly 48 hours ago', () => { + const executedAt = now - SPLIT_ACTIVE_DURATION_MS; + expect(isSplitActive({ executedAt }, now)).toBe(true); + }); + + it('returns false when split occurred 49 hours ago (> 48h)', () => { + const executedAt = now - 49 * 60 * 60 * 1000; + expect(isSplitActive({ executedAt }, now)).toBe(false); + }); + + it('returns false when split is null, undefined, or missing executedAt', () => { + expect(isSplitActive(null, now)).toBe(false); + expect(isSplitActive(undefined, now)).toBe(false); + expect(isSplitActive({}, now)).toBe(false); + }); + }); + + describe('getSplitRemainingMs', () => { + const now = 1770000000000; + + it('returns correct remaining milliseconds within 48h window', () => { + const elapsed = 10 * 3600 * 1000; + const executedAt = now - elapsed; + expect(getSplitRemainingMs({ executedAt }, now)).toBe( + SPLIT_ACTIVE_DURATION_MS - elapsed + ); + }); + + it('returns 0 when 48h have already passed', () => { + const executedAt = now - 50 * 3600 * 1000; + expect(getSplitRemainingMs({ executedAt }, now)).toBe(0); + }); + }); + + describe('formatSplitRatio', () => { + it('formats numeric ratios into ratio notation', () => { + expect(formatSplitRatio(2)).toBe('2:1'); + expect(formatSplitRatio(3)).toBe('3:1'); + expect(formatSplitRatio(5)).toBe('5:1'); + }); + + it('falls back to 1:1 for non-positive ratios', () => { + expect(formatSplitRatio(0)).toBe('1:1'); + expect(formatSplitRatio(-2)).toBe('1:1'); + }); + }); + + describe('resolveSplitBalances', () => { + const baseSplit: SplitExecutedEvent = { + keyId: 'test-key', + ratio: 2, + executedAt: Date.now(), + }; + + it('returns explicit preSplitBalance and postSplitBalance when both are present', () => { + const split: SplitExecutedEvent = { + ...baseSplit, + preSplitBalance: 10, + postSplitBalance: 20, + }; + const balances = resolveSplitBalances(split, 20); + expect(balances).toEqual({ preSplitBalance: 10, postSplitBalance: 20 }); + }); + + it('computes postSplitBalance from preSplitBalance and ratio', () => { + const split: SplitExecutedEvent = { + ...baseSplit, + preSplitBalance: 15, + }; + const balances = resolveSplitBalances(split); + expect(balances).toEqual({ preSplitBalance: 15, postSplitBalance: 30 }); + }); + + it('computes preSplitBalance from postSplitBalance and ratio', () => { + const split: SplitExecutedEvent = { + ...baseSplit, + postSplitBalance: 40, + }; + const balances = resolveSplitBalances(split); + expect(balances).toEqual({ preSplitBalance: 20, postSplitBalance: 40 }); + }); + + it('derives pre-split from active wallet holding when holding is post-split', () => { + const balances = resolveSplitBalances(baseSplit, 50, true); + expect(balances).toEqual({ preSplitBalance: 25, postSplitBalance: 50 }); + }); + + it('derives post-split from active wallet holding when holding is pre-split', () => { + const balances = resolveSplitBalances(baseSplit, 25, false); + expect(balances).toEqual({ preSplitBalance: 25, postSplitBalance: 50 }); + }); + + it('returns 0 for non-holders', () => { + const balances = resolveSplitBalances(baseSplit, 0); + expect(balances).toEqual({ preSplitBalance: 0, postSplitBalance: 0 }); + }); + }); + + describe('session dismissal', () => { + const keyId = 'creator-alpha'; + const executedAt = '2026-10-22T04:00:00.000Z'; + + it('starts as not dismissed', () => { + expect(isSplitDismissedForSession(keyId, executedAt)).toBe(false); + }); + + it('records dismissal in sessionStorage and returns true', () => { + dismissSplitForSession(keyId, executedAt); + expect(isSplitDismissedForSession(keyId, executedAt)).toBe(true); + }); + + it('clears dismissal with clearSplitDismissal', () => { + dismissSplitForSession(keyId, executedAt); + expect(isSplitDismissedForSession(keyId, executedAt)).toBe(true); + clearSplitDismissal(keyId); + expect(isSplitDismissedForSession(keyId, executedAt)).toBe(false); + }); + }); +}); diff --git a/src/utils/__tests__/tradeExport.utils.test.ts b/src/utils/__tests__/tradeExport.utils.test.ts new file mode 100644 index 00000000..b4ae585d --- /dev/null +++ b/src/utils/__tests__/tradeExport.utils.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { + buildTradeHistoryCsvFilename, + generateTradeHistoryCsv, + TRADE_HISTORY_CSV_HEADERS, +} from '@/utils/tradeLedger.utils'; +import type { Trade } from '@/services/tradeHistory.service'; + +const T = (iso: string) => Date.parse(iso); + +function makeTrade(i: number, over: Partial = {}): Trade { + return { + id: `t${i}`, + keyName: `Key ${i}`, + tradeType: i % 2 === 0 ? 'Buy' : 'Sell', + quantity: 2, + pricePerKey: 1.5, + timestamp: T('2026-10-01T12:00:00.000Z') + i * 60_000, + transactionHash: null, + feeXlm: 0.1, + ...over, + }; +} + +describe('trade history CSV export utils (#857)', () => { + describe('buildTradeHistoryCsvFilename', () => { + it('builds filename with truncated wallet and current date in YYYY-MM-DD format', () => { + const wallet = + 'GDEMOWALLET0000000000000000000000000000000000000000000000001'; + const date = T('2026-10-22T10:00:00.000Z'); + const filename = buildTradeHistoryCsvFilename(wallet, date); + + expect(filename).toBe('trades-GDEM…0001-2026-10-22.csv'); + expect(filename).toMatch(/^trades-.+-\d{4}-\d{2}-\d{2}\.csv$/); + }); + + it('handles short wallet address without errors', () => { + const filename = buildTradeHistoryCsvFilename( + 'SHORT', + T('2026-10-22T10:00:00.000Z') + ); + expect(filename).toBe('trades-SHORT-2026-10-22.csv'); + }); + }); + + describe('generateTradeHistoryCsv', () => { + it('generates CSV with exact headers: Date, Key Name, Type, Quantity, Price per Key, Total, Fee', () => { + const csv = generateTradeHistoryCsv([]); + expect(csv).toBe( + 'Date,Key Name,Type,Quantity,Price per Key,Total,Fee' + ); + expect(TRADE_HISTORY_CSV_HEADERS).toEqual([ + 'Date', + 'Key Name', + 'Type', + 'Quantity', + 'Price per Key', + 'Total', + 'Fee', + ]); + }); + + it('formats rows with correct columns, calculations, and RFC 4180 escaping', () => { + const trades: Trade[] = [ + makeTrade(0, { + keyName: 'Alpha Key', + tradeType: 'Buy', + quantity: 10, + pricePerKey: 2.5, + feeXlm: 0.25, + timestamp: T('2026-10-15T14:30:00.000Z'), + }), + makeTrade(1, { + keyName: 'Beta, "Pro" Key', + tradeType: 'Sell', + quantity: 4, + pricePerKey: 5.0, + feeXlm: 0.5, + timestamp: T('2026-10-16T09:15:00.000Z'), + }), + makeTrade(2, { + keyName: '=SUM(1,2)', + tradeType: 'Buy', + quantity: 1, + pricePerKey: 10, + feeXlm: undefined, + timestamp: T('2026-10-17T11:00:00.000Z'), + }), + ]; + + const csv = generateTradeHistoryCsv(trades); + const lines = csv.split('\r\n'); + + expect(lines).toHaveLength(4); // 1 header + 3 rows + expect(lines[0]).toBe( + 'Date,Key Name,Type,Quantity,Price per Key,Total,Fee' + ); + // Buy: subtotal = 25, fee = 0.25, total = 25.25 + expect(lines[1]).toBe( + '2026-10-15T14:30:00.000Z,Alpha Key,Buy,10,2.5,25.25,0.25' + ); + // Sell: subtotal = 20, fee = 0.5, total = 19.5, quotes escaped + expect(lines[2]).toBe( + '2026-10-16T09:15:00.000Z,"Beta, ""Pro"" Key",Sell,4,5,19.5,0.5' + ); + // Formula injection neutralized with prepended quote and wrapped in quotes due to comma + expect(lines[3]).toContain('"\'=SUM(1,2)",Buy,1,10,10,0'); + }); + }); +}); diff --git a/src/utils/keySplit.utils.ts b/src/utils/keySplit.utils.ts new file mode 100644 index 00000000..1df4861c --- /dev/null +++ b/src/utils/keySplit.utils.ts @@ -0,0 +1,192 @@ +import type { SplitExecutedEvent } from '@/types/keySplit'; + +/** Duration for which split notifications and portfolio badges remain active (#1151). */ +export const SPLIT_ACTIVE_DURATION_MS = 48 * 60 * 60 * 1000; // 48 hours + +const DISMISSAL_STORAGE_PREFIX = 'accesslayer.split_dismissed.'; + +/** + * Safely parses an ISO date string or epoch timestamp into milliseconds. + */ +export function parseTimestampMs( + timestamp: string | number | undefined | null +): number { + if (timestamp == null || timestamp === '') return NaN; + + if (typeof timestamp === 'number') { + // Unix seconds fallback (< year 2001 in ms) + return timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp; + } + + const parsed = Date.parse(timestamp); + if (!Number.isNaN(parsed)) return parsed; + + const numeric = Number(timestamp); + if (!Number.isNaN(numeric)) { + return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; + } + + return NaN; +} + +/** + * Determines whether a key split event is active (occurred within the last 48 hours). + */ +export function isSplitActive( + split: { executedAt?: string | number } | null | undefined, + now: number = Date.now() +): boolean { + if (!split?.executedAt) return false; + + const executedAtMs = parseTimestampMs(split.executedAt); + if (Number.isNaN(executedAtMs)) return false; + + // Split occurred in future or within the 48h active window + const elapsed = now - executedAtMs; + return elapsed >= 0 && elapsed <= SPLIT_ACTIVE_DURATION_MS; +} + +/** + * Calculates remaining active time for a split event in milliseconds. + */ +export function getSplitRemainingMs( + split: { executedAt?: string | number } | null | undefined, + now: number = Date.now() +): number { + if (!split?.executedAt) return 0; + const executedAtMs = parseTimestampMs(split.executedAt); + if (Number.isNaN(executedAtMs)) return 0; + + return Math.max(0, executedAtMs + SPLIT_ACTIVE_DURATION_MS - now); +} + +/** + * Formats a numeric split ratio into display strings (e.g. "2:1" or "2x"). + */ +export function formatSplitRatio(ratio: number): string { + if (!ratio || ratio <= 0) return '1:1'; + return `${ratio}:1`; +} + +/** + * Resolves the pre-split and post-split balances for a holder. + */ +export function resolveSplitBalances( + split: SplitExecutedEvent, + currentHoldingQuantity?: number, + isCurrentQuantityPostSplit: boolean = true +): { preSplitBalance: number; postSplitBalance: number } { + const ratio = split.ratio > 0 ? split.ratio : 1; + + // Explicit balances provided by event or payload + if (split.preSplitBalance != null && split.postSplitBalance != null) { + return { + preSplitBalance: split.preSplitBalance, + postSplitBalance: split.postSplitBalance, + }; + } + + if (split.preSplitBalance != null) { + return { + preSplitBalance: split.preSplitBalance, + postSplitBalance: Math.round(split.preSplitBalance * ratio), + }; + } + + if (split.postSplitBalance != null) { + return { + preSplitBalance: Math.floor(split.postSplitBalance / ratio), + postSplitBalance: split.postSplitBalance, + }; + } + + // Calculate from holder's active wallet balance + if (currentHoldingQuantity != null && currentHoldingQuantity > 0) { + if (isCurrentQuantityPostSplit) { + return { + preSplitBalance: Math.floor(currentHoldingQuantity / ratio), + postSplitBalance: currentHoldingQuantity, + }; + } + return { + preSplitBalance: currentHoldingQuantity, + postSplitBalance: Math.round(currentHoldingQuantity * ratio), + }; + } + + return { + preSplitBalance: 0, + postSplitBalance: 0, + }; +} + +/** + * Storage key for session dismissal of the split banner. + */ +export function getSplitDismissalKey( + keyId: string, + executedAt?: string | number +): string { + const timestampSuffix = executedAt ? `.${parseTimestampMs(executedAt)}` : ''; + return `${DISMISSAL_STORAGE_PREFIX}${keyId}${timestampSuffix}`; +} + +/** + * Checks whether the split banner for this key has been dismissed for the current session. + */ +export function isSplitDismissedForSession( + keyId: string, + executedAt?: string | number +): boolean { + if (typeof window === 'undefined' || !window.sessionStorage) return false; + + try { + const specificKey = getSplitDismissalKey(keyId, executedAt); + if (window.sessionStorage.getItem(specificKey) === 'true') return true; + + // Fallback check by keyId alone + const generalKey = `${DISMISSAL_STORAGE_PREFIX}${keyId}`; + return window.sessionStorage.getItem(generalKey) === 'true'; + } catch { + return false; + } +} + +/** + * Dismisses the split banner for this key for the remainder of the session. + */ +export function dismissSplitForSession( + keyId: string, + executedAt?: string | number +): void { + if (typeof window === 'undefined' || !window.sessionStorage) return; + + try { + const specificKey = getSplitDismissalKey(keyId, executedAt); + window.sessionStorage.setItem(specificKey, 'true'); + window.sessionStorage.setItem(`${DISMISSAL_STORAGE_PREFIX}${keyId}`, 'true'); + } catch { + // Ignore session storage errors (e.g. private browsing quota) + } +} + +/** + * Clears session dismissal for testing or session reset. + */ +export function clearSplitDismissal(keyId?: string): void { + if (typeof window === 'undefined' || !window.sessionStorage) return; + + try { + const targetPrefix = keyId ? `${DISMISSAL_STORAGE_PREFIX}${keyId}` : DISMISSAL_STORAGE_PREFIX; + const keysToRemove: string[] = []; + for (let i = 0; i < window.sessionStorage.length; i++) { + const key = window.sessionStorage.key(i); + if (key?.startsWith(targetPrefix)) { + keysToRemove.push(key); + } + } + keysToRemove.forEach(k => window.sessionStorage.removeItem(k)); + } catch { + // Ignore session storage errors + } +} diff --git a/src/utils/tradeLedger.utils.ts b/src/utils/tradeLedger.utils.ts index 1c3288e7..c07d9a31 100644 --- a/src/utils/tradeLedger.utils.ts +++ b/src/utils/tradeLedger.utils.ts @@ -2,6 +2,7 @@ * Helpers for the portfolio trade history table and its CSV export (#1114). */ import type { Trade } from '@/services/tradeHistory.service'; +import { truncateWalletAddress } from '@/utils/referral.utils'; export const TRADE_LEDGER_PAGE_SIZE = 10; @@ -126,3 +127,50 @@ export function tradesToCsv(trades: Trade[]): string { export function buildTradeCsvFilename(now: number = Date.now()): string { return `accesslayer-trade-history-${new Date(now).toISOString().slice(0, 10)}.csv`; } + +export const TRADE_HISTORY_CSV_HEADERS = [ + 'Date', + 'Key Name', + 'Type', + 'Quantity', + 'Price per Key', + 'Total', + 'Fee', +] as const; + +/** + * Generates formatted CSV content for trade history export (#857). + * Columns: Date, Key Name, Type, Quantity, Price per Key, Total, Fee + */ +export function generateTradeHistoryCsv(trades: Trade[]): string { + const rows = trades.map(trade => { + const { fee, total } = computeTradeAmounts(trade); + return [ + new Date(trade.timestamp).toISOString(), + trade.keyName, + trade.tradeType, + String(trade.quantity), + String(trade.pricePerKey), + String(total), + String(fee), + ].map(escapeCsvField); + }); + return [[...TRADE_HISTORY_CSV_HEADERS], ...rows] + .map(row => row.join(',')) + .join('\r\n'); +} + +/** + * Builds the export filename for trade history (#857): + * trades-[wallet-truncated]-[date].csv + */ +export function buildTradeHistoryCsvFilename( + wallet: string, + now: number | Date | string = Date.now() +): string { + const truncated = truncateWalletAddress(wallet) || wallet; + const dateObj = + typeof now === 'number' || typeof now === 'string' ? new Date(now) : now; + const date = dateObj.toISOString().slice(0, 10); + return `trades-${truncated}-${date}.csv`; +}