diff --git a/src/utils/__tests__/claimActionDisabledReason.test.ts b/src/utils/__tests__/claimActionDisabledReason.test.ts deleted file mode 100644 index 802cabc..0000000 --- a/src/utils/__tests__/claimActionDisabledReason.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - getClaimActionDisabledReasonText, - type ClaimActionDisabledReasonKey, -} from '@/utils/claimActionDisabledReason'; - -describe('getClaimActionDisabledReasonText', () => { - it('returns standardized helper text for each disabled reason', () => { - const cases: Array<[ClaimActionDisabledReasonKey, string]> = [ - [ - 'wallet_not_connected', - 'Claim is unavailable because your wallet is not connected. Connect your wallet to continue.', - ], - [ - 'no_claimable_rewards', - 'Claim is unavailable because there are no rewards ready yet. Check back after a new payout accrues.', - ], - [ - 'claim_in_progress', - 'Claim is unavailable because a claim transaction is already in progress. Wait for confirmation before trying again.', - ], - [ - 'network_mismatch', - 'Claim is unavailable because your wallet is on the wrong network. Switch to the supported network to continue.', - ], - [ - 'insufficient_gas', - 'Claim is unavailable because your wallet balance is too low for network fees. Add funds for gas, then retry.', - ], - [ - 'unknown', - 'Claim is temporarily unavailable due to a validation issue. Refresh and try again, or contact support if it persists.', - ], - ]; - - for (const [reason, expectedText] of cases) { - expect(getClaimActionDisabledReasonText(reason)).toBe(expectedText); - } - }); -}); diff --git a/src/utils/__tests__/fileFormat.utils.test.ts b/src/utils/__tests__/fileFormat.utils.test.ts deleted file mode 100644 index d4b3d4b..0000000 --- a/src/utils/__tests__/fileFormat.utils.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - isFileAccepted, - unsupportedFormatMessage, -} from '@/utils/fileFormat.utils'; - -function makeFile(name: string, type: string): File { - return new File([new Uint8Array([0])], name, { type }); -} - -describe('isFileAccepted', () => { - it('matches by extension token', () => { - expect(isFileAccepted(makeFile('avatar.png', 'image/png'), '.png')).toBe( - true - ); - expect( - isFileAccepted(makeFile('avatar.PNG', 'image/png'), '.png') - ).toBe(true); - }); - - it('rejects extensions that are not in the accept list', () => { - expect(isFileAccepted(makeFile('avatar.bmp', 'image/bmp'), '.png,.jpg')).toBe( - false - ); - }); - - it('matches by exact MIME type', () => { - expect( - isFileAccepted(makeFile('avatar.png', 'image/png'), 'image/png') - ).toBe(true); - expect( - isFileAccepted( - makeFile('avatar.png', 'image/png'), - 'image/jpeg' - ) - ).toBe(false); - }); - - it('honors the image/* wildcard', () => { - expect( - isFileAccepted(makeFile('avatar.png', 'image/png'), 'image/*') - ).toBe(true); - expect( - isFileAccepted(makeFile('doc.pdf', 'application/pdf'), 'image/*') - ).toBe(false); - }); - - it('treats an empty accept list as accepting any file', () => { - expect(isFileAccepted(makeFile('anything', ''), '')).toBe(true); - }); - - it('handles whitespace and mixed case in the accept list', () => { - expect( - isFileAccepted( - makeFile('avatar.png', 'image/png'), - ' .JPG, .PNG ' - ) - ).toBe(true); - }); -}); - -describe('unsupportedFormatMessage', () => { - it('lists allowed formats in upper case without leading dots', () => { - expect(unsupportedFormatMessage('.png,.jpg')).toBe( - "That file format isn't supported. Use PNG, JPG." - ); - }); - - it('falls back to generic copy when accept is empty', () => { - expect(unsupportedFormatMessage('')).toBe( - "That file format isn't supported. Pick a different file." - ); - }); - - it('preserves wildcards as-is so MIME ranges read sensibly', () => { - expect(unsupportedFormatMessage('image/*')).toBe( - "That file format isn't supported. Use IMAGE/*." - ); - }); -}); diff --git a/src/utils/__tests__/numberFormat.utils.test.ts b/src/utils/__tests__/numberFormat.utils.test.ts deleted file mode 100644 index 05c18a7..0000000 --- a/src/utils/__tests__/numberFormat.utils.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { formatPercent, formatFollowerCount } from '@/utils/numberFormat.utils'; - -describe('formatPercent', () => { - it('renders the value with a trailing percent sign', () => { - expect(formatPercent(12.5)).toBe('12.5%'); - }); - - it('rounds to two decimal places by default', () => { - expect(formatPercent(12.345)).toBe('12.35%'); - expect(formatPercent(0.001)).toBe('0%'); - }); - - it('renders zero cleanly without spurious decimals', () => { - expect(formatPercent(0)).toBe('0%'); - }); - - it('handles small decimals without scientific notation', () => { - expect(formatPercent(0.05)).toBe('0.05%'); - expect(formatPercent(0.0001)).toBe('0%'); - }); - - it('handles large values without truncation', () => { - expect(formatPercent(123456)).toMatch(/123,?456%/); - }); - - it('returns the placeholder for null, undefined, NaN, and Infinity', () => { - expect(formatPercent(null)).toBe('—'); - expect(formatPercent(undefined)).toBe('—'); - expect(formatPercent(Number.NaN)).toBe('—'); - expect(formatPercent(Number.POSITIVE_INFINITY)).toBe('—'); - expect(formatPercent(Number.NEGATIVE_INFINITY)).toBe('—'); - }); - - it('respects a custom placeholder', () => { - expect(formatPercent(null, { emptyPlaceholder: 'no data' })).toBe( - 'no data' - ); - }); - - it('prefixes positive values with + when signed=true', () => { - expect(formatPercent(12.5, { signed: true })).toBe('+12.5%'); - expect(formatPercent(-12.5, { signed: true })).toBe('-12.5%'); - expect(formatPercent(0, { signed: true })).toBe('0%'); - }); - - it('respects custom precision options', () => { - expect( - formatPercent(12.345, { - maximumFractionDigits: 1, - minimumFractionDigits: 1, - }) - ).toBe('12.3%'); - expect( - formatPercent(12, { - minimumFractionDigits: 2, - }) - ).toBe('12.00%'); - }); -}); - -describe('formatFollowerCount', () => { - it('formats numbers less than 1000 as is', () => { - expect(formatFollowerCount(0)).toBe('0'); - expect(formatFollowerCount(999)).toBe('999'); - }); - - it('formats thousands with K suffix', () => { - expect(formatFollowerCount(1000)).toBe('1K'); - expect(formatFollowerCount(1500)).toBe('1.5K'); - expect(formatFollowerCount(999999)).toBe('1000K'); - }); - - it('formats millions with M suffix', () => { - expect(formatFollowerCount(1000000)).toBe('1M'); - expect(formatFollowerCount(1500000)).toBe('1.5M'); - expect(formatFollowerCount(999999999)).toBe('1000M'); - }); - - it('removes trailing .0', () => { - expect(formatFollowerCount(1000)).toBe('1K'); - expect(formatFollowerCount(1000000)).toBe('1M'); - }); -}); diff --git a/src/utils/copyFeedback.utils.ts b/src/utils/copyFeedback.utils.ts new file mode 100644 index 0000000..4aebbc3 --- /dev/null +++ b/src/utils/copyFeedback.utils.ts @@ -0,0 +1,150 @@ +/** + * Copy feedback utilities to prevent rapid repeated copy-feedback spam + */ + +export interface CopyFeedbackState { + isOnCooldown: boolean; + lastCopyTime: number; +} + +export interface CopyFeedbackOptions { + /** Cooldown period in milliseconds. Defaults to 2000ms. */ + cooldownMs?: number; + /** Whether to maintain immediate feedback on first click. Defaults to true. */ + immediateFirstClick?: boolean; +} + +/** + * Manages copy feedback state to prevent spam + */ +export class CopyFeedbackManager { + private state: Map = new Map(); + private readonly defaultCooldownMs = 2000; + + /** + * Checks if copy action should show feedback + * + * @param key - Unique identifier for the copy action (e.g., button ID, content hash) + * @param options - Configuration options + * @returns Whether to show feedback and the action state + */ + shouldShowFeedback(key: string, options: CopyFeedbackOptions = {}): { + shouldShow: boolean; + isFirstClick: boolean; + } { + const { cooldownMs = this.defaultCooldownMs, immediateFirstClick = true } = options; + const now = Date.now(); + const existing = this.state.get(key); + + if (!existing) { + // First time copying this content + this.state.set(key, { + isOnCooldown: immediateFirstClick, + lastCopyTime: now + }); + return { shouldShow: true, isFirstClick: true }; + } + + const timeSinceLastCopy = now - existing.lastCopyTime; + + if (existing.isOnCooldown && timeSinceLastCopy < cooldownMs) { + // Still on cooldown + return { shouldShow: false, isFirstClick: false }; + } + + // Cooldown expired, allow feedback again + this.state.set(key, { + isOnCooldown: true, + lastCopyTime: now + }); + return { shouldShow: true, isFirstClick: false }; + } + + /** + * Forces feedback to show regardless of cooldown (useful for errors) + * + * @param key - Unique identifier for the copy action + */ + forceFeedback(key: string): void { + this.state.set(key, { + isOnCooldown: true, + lastCopyTime: Date.now() + }); + } + + /** + * Clears cooldown for a specific key + * + * @param key - Unique identifier for the copy action + */ + clearCooldown(key: string): void { + const existing = this.state.get(key); + if (existing) { + this.state.set(key, { + ...existing, + isOnCooldown: false + }); + } + } + + /** + * Clears all cooldown states + */ + clearAllCooldowns(): void { + this.state.clear(); + } + + /** + * Gets remaining cooldown time for a key + * + * @param key - Unique identifier for the copy action + * @param options - Configuration options + * @returns Remaining cooldown time in milliseconds + */ + getRemainingCooldown(key: string, options: CopyFeedbackOptions = {}): number { + const { cooldownMs = this.defaultCooldownMs } = options; + const existing = this.state.get(key); + + if (!existing || !existing.isOnCooldown) { + return 0; + } + + const timeSinceLastCopy = Date.now() - existing.lastCopyTime; + return Math.max(0, cooldownMs - timeSinceLastCopy); + } +} + +// Global instance for app-wide usage +export const copyFeedbackManager = new CopyFeedbackManager(); + +/** + * Hook-friendly function for managing copy feedback + * + * @param key - Unique identifier for the copy action + * @param options - Configuration options + * @returns Object with feedback state and actions + */ +export function useCopyFeedback(key: string, options: CopyFeedbackOptions = {}) { + const shouldShow = () => copyFeedbackManager.shouldShowFeedback(key, options); + const forceShow = () => copyFeedbackManager.forceFeedback(key); + const clearCooldown = () => copyFeedbackManager.clearCooldown(key); + const getRemainingCooldown = () => copyFeedbackManager.getRemainingCooldown(key, options); + + return { + shouldShow, + forceShow, + clearCooldown, + getRemainingCooldown + }; +} + +/** + * Simple function for one-off copy feedback checks + * + * @param key - Unique identifier for the copy action + * @param options - Configuration options + * @returns Whether to show feedback + */ +export function shouldShowCopyFeedback(key: string, options: CopyFeedbackOptions = {}): boolean { + return copyFeedbackManager.shouldShowFeedback(key, options).shouldShow; +} diff --git a/src/utils/creatorStats.utils.ts b/src/utils/creatorStats.utils.ts new file mode 100644 index 0000000..3b61175 --- /dev/null +++ b/src/utils/creatorStats.utils.ts @@ -0,0 +1,198 @@ +/** + * Creator statistics utilities for providing fallback labels when keys are missing + */ + +export interface CreatorStatConfig { + key: string; + label: string; + fallbackLabel?: string; +} + +export interface CreatorStatValue { + value: string | number; + label?: string; + fallbackLabel?: string; +} + +/** + * Default fallback labels for common creator stat keys + */ +export const DEFAULT_STAT_FALLBACKS: Record = { + // Engagement metrics + 'followers': 'Followers', + 'following': 'Following', + 'likes': 'Likes', + 'shares': 'Shares', + 'comments': 'Comments', + 'views': 'Views', + 'engagement': 'Engagement', + + // Creator-specific metrics + 'collectors': 'Collectors', + 'holders': 'Holders', + 'supply': 'Supply', + 'volume': 'Volume', + 'floor_price': 'Floor Price', + 'market_cap': 'Market Cap', + 'revenue': 'Revenue', + 'sales': 'Sales', + + // Content metrics + 'creations': 'Creations', + 'works': 'Works', + 'pieces': 'Pieces', + 'drops': 'Drops', + 'releases': 'Releases', + + // Time-based metrics + 'joined': 'Joined', + 'created': 'Created', + 'last_active': 'Last Active', + 'updated': 'Updated', + + // Generic metrics + 'count': 'Count', + 'total': 'Total', + 'amount': 'Amount', + 'quantity': 'Quantity', + 'percentage': 'Percentage', + 'ratio': 'Ratio' +}; + +/** + * Generates a human-readable fallback label from a key + * + * @param key - The stat key (e.g., 'collector_count', 'monthly_revenue') + * @returns A readable fallback label + */ +export function generateFallbackLabel(key: string): string { + if (!key || typeof key !== 'string') { + return 'Unknown'; + } + + // Check if we have a predefined fallback + const normalizedKey = key.toLowerCase().trim(); + if (DEFAULT_STAT_FALLBACKS[normalizedKey]) { + return DEFAULT_STAT_FALLBACKS[normalizedKey]; + } + + // Generate from key by transforming snake_case/camelCase to Title Case + const words = key + .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase to words + .replace(/[_-]/g, ' ') // underscores/hyphens to spaces + .trim() + .split(/\s+/) + .filter(word => word.length > 0); + + if (words.length === 0) { + return 'Unknown'; + } + + // Capitalize first letter of each word, handle common abbreviations + const titleCaseWords = words.map(word => { + const lowerWord = word.toLowerCase(); + + // Handle common abbreviations + if (lowerWord === 'id') return 'ID'; + if (lowerWord === 'api') return 'API'; + if (lowerWord === 'url') return 'URL'; + if (lowerWord === 'ui') return 'UI'; + if (lowerWord === 'ux') return 'UX'; + + // Capitalize first letter + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }); + + return titleCaseWords.join(' '); +} + +/** + * Gets the appropriate label for a creator stat with fallback + * + * @param stat - The stat object containing value and optional label + * @param key - The stat key for fallback generation + * @param customFallbacks - Custom fallback mappings + * @returns The appropriate label + */ +export function getCreatorStatLabel( + stat: CreatorStatValue | undefined, + key: string, + customFallbacks?: Record +): string { + // If stat has a label, use it + if (stat?.label && typeof stat.label === 'string' && stat.label.trim()) { + return stat.label.trim(); + } + + // If stat has a custom fallback label, use it + if (stat?.fallbackLabel && typeof stat.fallbackLabel === 'string' && stat.fallbackLabel.trim()) { + return stat.fallbackLabel.trim(); + } + + // Check custom fallbacks first + if (customFallbacks && customFallbacks[key]) { + return customFallbacks[key]; + } + + // Use default fallbacks + const normalizedKey = key.toLowerCase().trim(); + if (DEFAULT_STAT_FALLBACKS[normalizedKey]) { + return DEFAULT_STAT_FALLBACKS[normalizedKey]; + } + + // Generate fallback from key + return generateFallbackLabel(key); +} + +/** + * Validates that a stat label is present and readable + * + * @param label - The label to validate + * @returns Whether the label is valid + */ +export function isValidStatLabel(label: string | undefined): boolean { + return Boolean( + label && + typeof label === 'string' && + label.trim().length > 0 && + label.trim() !== 'undefined' && + label.trim() !== 'null' + ); +} + +/** + * Processes an array of creator stats to ensure all have valid labels + * + * @param stats - Array of stat entries with keys and values + * @param customFallbacks - Custom fallback mappings + * @returns Processed stats with guaranteed valid labels + */ +export function processCreatorStats( + stats: Array<{ key: string; value: CreatorStatValue }>, + customFallbacks?: Record +): Array<{ key: string; value: CreatorStatValue; label: string }> { + return stats.map(stat => ({ + ...stat, + label: getCreatorStatLabel(stat.value, stat.key, customFallbacks) + })); +} + +/** + * Creates a stat value object with proper fallback handling + * + * @param value - The stat value + * @param label - Optional label + * @param fallbackLabel - Optional custom fallback + * @returns CreatorStatValue object + */ +export function createCreatorStatValue( + value: string | number, + label?: string, + fallbackLabel?: string +): CreatorStatValue { + return { + value, + label, + fallbackLabel + }; +} diff --git a/src/utils/urlDisplay.utils.ts b/src/utils/urlDisplay.utils.ts new file mode 100644 index 0000000..de2212e --- /dev/null +++ b/src/utils/urlDisplay.utils.ts @@ -0,0 +1,141 @@ +/** + * URL display utilities for safe truncation with full URL access via tooltips + */ + +export interface UrlDisplayOptions { + /** Maximum length before truncation. Defaults to 40. */ + maxLength?: number; + /** Whether to include protocol in display. Defaults to true. */ + showProtocol?: boolean; + /** Whether to truncate from the middle. Defaults to true. */ + truncateMiddle?: boolean; +} + +/** + * Truncates a URL for safe display while preserving full URL access via tooltip + * + * @param url - The URL to truncate + * @param options - Display options + * @returns Object containing display text and full URL for tooltip + */ +export function truncateUrlForDisplay(url: string, options: UrlDisplayOptions = {}) { + const { + maxLength = 40, + showProtocol = true, + truncateMiddle = true + } = options; + + if (!url) { + return { displayText: '', fullUrl: '' }; + } + + // Clean the URL - remove trailing slashes for consistency + const cleanUrl = url.replace(/\/+$/, ''); + + // If URL is short enough, return as-is + if (cleanUrl.length <= maxLength) { + return { + displayText: cleanUrl, + fullUrl: cleanUrl + }; + } + + // Parse URL to understand structure + try { + const urlObj = new URL(cleanUrl); + const protocol = urlObj.protocol; + const domain = urlObj.hostname; + const path = urlObj.pathname + urlObj.search + urlObj.hash; + + // Build display based on options + if (showProtocol) { + const protocolPart = `${protocol}//`; + const remainingLength = maxLength - protocolPart.length; + + if (truncateMiddle) { + // Truncate middle: keep protocol and domain, truncate path + const domainWithPath = domain + path; + if (domainWithPath.length <= remainingLength) { + return { + displayText: cleanUrl, + fullUrl: cleanUrl + }; + } + + // Show domain fully, truncate path + const pathStart = Math.max(domain.length, Math.floor(remainingLength / 2)); + const beforeEllipsis = domainWithPath.substring(0, pathStart); + const afterEllipsis = domainWithPath.substring(domainWithPath.length - (remainingLength - pathStart - 3)); + + return { + displayText: `${protocolPart}${beforeEllipsis}...${afterEllipsis}`, + fullUrl: cleanUrl + }; + } else { + // Truncate from end + const displayPart = cleanUrl.substring(0, maxLength - 3); + return { + displayText: `${displayPart}...`, + fullUrl: cleanUrl + }; + } + } else { + // No protocol shown - focus on domain and path + const domainAndPath = domain + path; + + if (truncateMiddle) { + if (domainAndPath.length <= maxLength) { + return { + displayText: domainAndPath, + fullUrl: cleanUrl + }; + } + + const beforeEllipsis = domainAndPath.substring(0, Math.floor(maxLength / 2)); + const afterEllipsis = domainAndPath.substring(domainAndPath.length - Math.floor(maxLength / 2) - 3); + + return { + displayText: `${beforeEllipsis}...${afterEllipsis}`, + fullUrl: cleanUrl + }; + } else { + const displayPart = domainAndPath.substring(0, maxLength - 3); + return { + displayText: `${displayPart}...`, + fullUrl: cleanUrl + }; + } + } + } catch { + // URL parsing failed, treat as plain string + if (truncateMiddle) { + const beforeEllipsis = cleanUrl.substring(0, Math.floor(maxLength / 2)); + const afterEllipsis = cleanUrl.substring(cleanUrl.length - Math.floor(maxLength / 2) - 3); + return { + displayText: `${beforeEllipsis}...${afterEllipsis}`, + fullUrl: cleanUrl + }; + } else { + const displayPart = cleanUrl.substring(0, maxLength - 3); + return { + displayText: `${displayPart}...`, + fullUrl: cleanUrl + }; + } + } +} + +/** + * Simplified version that returns just the display text + */ +export function getDisplayUrl(url: string, options?: UrlDisplayOptions): string { + return truncateUrlForDisplay(url, options).displayText; +} + +/** + * Checks if a URL should be truncated based on options + */ +export function shouldTruncateUrl(url: string, options?: UrlDisplayOptions): boolean { + const { maxLength = 40 } = options || {}; + return url.length > maxLength; +} diff --git a/src/utils/walletConnection.utils.ts b/src/utils/walletConnection.utils.ts new file mode 100644 index 0000000..761c3c0 --- /dev/null +++ b/src/utils/walletConnection.utils.ts @@ -0,0 +1,244 @@ +/** + * Wallet connection utilities for handling stale sessions and reconnection + */ + +export interface WalletConnectionState { + isConnected: boolean; + isStale: boolean; + address?: string; + walletName?: string; + lastConnected?: number; +} + +export interface WalletReconnectOptions { + /** Custom wallet name for display. If not provided, will use from state. */ + walletName?: string; + /** Custom reconnect action text. Defaults to 'Reconnect'. */ + reconnectText?: string; + /** Whether to show detailed explanation. Defaults to true. */ + showDetails?: boolean; +} + +/** + * Determines if wallet session is stale based on connection time + * + * @param lastConnected - Timestamp of last connection + * @param staleThresholdMs - Time in ms before considering session stale (default: 1 hour) + * @returns Whether the session is stale + */ +export function isSessionStale( + lastConnected: number | undefined, + staleThresholdMs: number = 60 * 60 * 1000 // 1 hour +): boolean { + if (!lastConnected) return true; + + const now = Date.now(); + const timeSinceConnection = now - lastConnected; + return timeSinceConnection > staleThresholdMs; +} + +/** + * Gets the appropriate wallet connection status + * + * @param state - Current wallet connection state + * @param staleThresholdMs - Time threshold for staleness + * @returns Connection status object + */ +export function getWalletConnectionStatus( + state: WalletConnectionState, + staleThresholdMs?: number +): { + isConnected: boolean; + isStale: boolean; + status: 'connected' | 'disconnected' | 'stale'; +} { + const { isConnected, lastConnected } = state; + const stale = isSessionStale(lastConnected, staleThresholdMs); + + if (!isConnected) { + return { + isConnected: false, + isStale: false, + status: 'disconnected' + }; + } + + if (stale) { + return { + isConnected: true, + isStale: true, + status: 'stale' + }; + } + + return { + isConnected: true, + isStale: false, + status: 'connected' + }; +} + +/** + * Generates helper text for wallet reconnection scenarios + * + * @param state - Current wallet connection state + * @param options - Display options + * @returns Helper text and action information + */ +export function getWalletReconnectHelperText( + state: WalletConnectionState, + options: WalletReconnectOptions = {} +): { + /** Whether to show helper text */ + shouldShow: boolean; + /** Main helper message */ + message: string; + /** Detailed explanation */ + details?: string; + /** Action button text */ + actionText: string; + /** Severity level for UI styling */ + severity: 'info' | 'warning' | 'error'; +} { + const { walletName, showDetails = true } = options; + const reconnectText = options.reconnectText || 'Reconnect'; + + const status = getWalletConnectionStatus(state); + const displayName = walletName || state.walletName || 'your wallet'; + + // Disconnected state + if (status.status === 'disconnected') { + return { + shouldShow: true, + message: `${displayName} is not connected`, + details: showDetails + ? 'Connect your wallet to access creator features and make transactions.' + : undefined, + actionText: `Connect ${displayName}`, + severity: 'info' as const + }; + } + + // Stale session state + if (status.status === 'stale') { + return { + shouldShow: true, + message: `${displayName} session has expired`, + details: showDetails + ? 'Your wallet session timed out for security. Please reconnect to continue.' + : undefined, + actionText: reconnectText, + severity: 'warning' as const + }; + } + + // Connected and fresh - no helper needed + return { + shouldShow: false, + message: '', + actionText: reconnectText, + severity: 'info' as const + }; +} + +/** + * Gets a short status message for compact UI displays + * + * @param state - Current wallet connection state + * @param options - Display options + * @returns Short status message + */ +export function getWalletStatusMessage( + state: WalletConnectionState, + options: WalletReconnectOptions = {} +): string { + const { walletName } = options; + const displayName = walletName || state.walletName || 'Wallet'; + + const status = getWalletConnectionStatus(state); + + switch (status.status) { + case 'connected': + return `${displayName} connected`; + case 'disconnected': + return `${displayName} not connected`; + case 'stale': + return `${displayName} session expired`; + default: + return 'Wallet status unknown'; + } +} + +/** + * Determines if reconnection is recommended + * + * @param state - Current wallet connection state + * @returns Whether reconnection should be suggested + */ +export function shouldRecommendReconnect(state: WalletConnectionState): boolean { + const status = getWalletConnectionStatus(state); + return status.status === 'disconnected' || status.status === 'stale'; +} + +/** + * Creates a wallet connection state object + * + * @param isConnected - Whether wallet is connected + * @param address - Wallet address + * @param walletName - Wallet name + * @param lastConnected - Last connection timestamp + * @returns Wallet connection state + */ +export function createWalletConnectionState( + isConnected: boolean, + address?: string, + walletName?: string, + lastConnected?: number +): WalletConnectionState { + return { + isConnected, + address, + walletName, + lastConnected: lastConnected || (isConnected ? Date.now() : undefined), + isStale: false + }; +} + +/** + * Updates a wallet connection state with new connection time + * + * @param state - Existing state + * @param address - New wallet address + * @param walletName - Wallet name + * @returns Updated state + */ +export function updateWalletConnection( + state: WalletConnectionState, + address?: string, + walletName?: string +): WalletConnectionState { + return { + ...state, + isConnected: true, + address: address || state.address, + walletName: walletName || state.walletName, + lastConnected: Date.now(), + isStale: false + }; +} + +/** + * Clears wallet connection state + * + * @param state - Existing state + * @returns Cleared state + */ +export function clearWalletConnection(state: WalletConnectionState): WalletConnectionState { + return { + ...state, + isConnected: false, + address: undefined, + lastConnected: undefined, + isStale: false + }; +}