import { Add, CheckCircleOutline, Close, CopyAllTwoTone, Edit, ErrorOutline, FileDownloadOutlined, InfoOutlined, LockOutlined, NorthEast, Refresh, Search, SouthWest, Star, StarBorder, Sync, VerifiedRounded, } from '@mui/icons-material'; import { Avatar, Box, Button, Collapse, Dialog, DialogContent, DialogTitle, IconButton, InputAdornment, LinearProgress, TablePagination, TextField, Typography, } from '@mui/material'; import type { Theme } from '@mui/material/styles'; import { Coin } from 'qapp-core'; import { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import QRCode from 'react-qr-code'; import arrrCoinRender from '../../assets/wallet-renders/arrr-coin-render.png'; import arrrCoinIcon from '../../assets/arrr.png'; import btcCoinRender from '../../assets/wallet-renders/btc-coin-render.png'; import btcCoinIcon from '../../assets/btc.png'; import dgbCoinRender from '../../assets/wallet-renders/dgb-coin-render.png'; import dgbCoinIcon from '../../assets/dgb.png'; import dogeCoinRender from '../../assets/wallet-renders/doge-coin-render.png'; import dogeCoinIcon from '../../assets/doge.png'; import ltcCoinRender from '../../assets/wallet-renders/ltc-coin-render.png'; import ltcCoinIcon from '../../assets/ltc.png'; import qortCoinRender from '../../assets/wallet-renders/qort-coin-render.png'; import qortCoinIcon from '../../assets/qort.png'; import rvnCoinRender from '../../assets/wallet-renders/rvn-coin-render.png'; import rvnCoinIcon from '../../assets/rvn.png'; import { copyToClipboard, cropString, epochToAgo, } from '../../common/functions'; import { CustomWidthTooltip, WalletButtons, WalletCard, } from '../../styles/page-styles'; import { AddressBookEntry } from '../../utils/Types'; import { ADDRESS_BOOK_STORAGE_EVENT, deleteAddress, getAddressBook, moveAddressBookEntry, toggleAddressBookFavorite, updateAddress, } from '../../utils/addressBookStorage'; import { DeleteConfirmationDialog } from '../AddressBook/DeleteConfirmationDialog'; import { AddressFormDialog } from '../AddressBook/AddressFormDialog'; import { NameText } from '../NameText'; import { getAddressBookAvatarColor, getAddressBookAvatarSx, } from '../AddressBook/avatarPalette'; import { useTranslation } from 'react-i18next'; export type WalletCoinSymbol = | 'QORT' | 'BTC' | 'LTC' | 'DOGE' | 'DGB' | 'RVN' | 'ARRR'; type WalletVisual = { accent: string; coinIcon: string; coinImage: string; coinType: Coin; decimals: number; glow: string; glowSoft: string; name: string; symbol: WalletCoinSymbol; }; const WALLET_VISUALS: Record = { QORT: { accent: '#18bdf2', coinIcon: qortCoinIcon, coinImage: qortCoinRender, coinType: Coin.QORT, decimals: 2, glow: 'rgba(24, 189, 242, 0.42)', glowSoft: 'rgba(24, 189, 242, 0.13)', name: 'Qortal', symbol: 'QORT', }, BTC: { accent: '#f6a70b', coinIcon: btcCoinIcon, coinImage: btcCoinRender, coinType: Coin.BTC, decimals: 8, glow: 'rgba(246, 167, 11, 0.38)', glowSoft: 'rgba(246, 167, 11, 0.11)', name: 'Bitcoin', symbol: 'BTC', }, LTC: { accent: '#b9c4d4', coinIcon: ltcCoinIcon, coinImage: ltcCoinRender, coinType: Coin.LTC, decimals: 8, glow: 'rgba(185, 196, 212, 0.36)', glowSoft: 'rgba(185, 196, 212, 0.12)', name: 'Litecoin', symbol: 'LTC', }, DOGE: { accent: '#d7aa36', coinIcon: dogeCoinIcon, coinImage: dogeCoinRender, coinType: Coin.DOGE, decimals: 8, glow: 'rgba(215, 170, 54, 0.36)', glowSoft: 'rgba(215, 170, 54, 0.11)', name: 'Dogecoin', symbol: 'DOGE', }, DGB: { accent: '#2a75d9', coinIcon: dgbCoinIcon, coinImage: dgbCoinRender, coinType: Coin.DGB, decimals: 8, glow: 'rgba(42, 117, 217, 0.36)', glowSoft: 'rgba(42, 117, 217, 0.12)', name: 'DigiByte', symbol: 'DGB', }, RVN: { accent: '#f09a38', coinIcon: rvnCoinIcon, coinImage: rvnCoinRender, coinType: Coin.RVN, decimals: 8, glow: 'rgba(240, 154, 56, 0.36)', glowSoft: 'rgba(56, 102, 214, 0.14)', name: 'Ravencoin', symbol: 'RVN', }, ARRR: { accent: '#e0b64a', coinIcon: arrrCoinIcon, coinImage: arrrCoinRender, coinType: Coin.ARRR, decimals: 8, glow: 'rgba(224, 182, 74, 0.36)', glowSoft: 'rgba(224, 182, 74, 0.11)', name: 'Pirate Chain', symbol: 'ARRR', }, }; const getWalletVars = (visual: WalletVisual) => ({ '--wallet-accent': visual.accent, '--wallet-coin-image': `url(${visual.coinImage})`, '--wallet-glow': visual.glow, '--wallet-glow-soft': visual.glowSoft, }) as Record; type WalletGlowLayerKey = 'coinShadow' | 'floorReflection' | 'floorShadow'; type WalletGlowLayerSettings = { blur: number; intensity: number; spread: number; x: number; y: number; }; type WalletGlowDevSettings = Record< WalletGlowLayerKey, WalletGlowLayerSettings >; const DEFAULT_WALLET_GLOW_SETTINGS: WalletGlowDevSettings = { coinShadow: { blur: 57, intensity: 75, spread: 75, x: -7, y: 2 }, floorReflection: { blur: 8, intensity: 220, spread: 109, x: -16, y: 14 }, floorShadow: { blur: 7, intensity: 220, spread: 100, x: -7, y: 0 }, }; const createWalletGlowCssVars = (settings: WalletGlowDevSettings) => { const layerVars = (prefix: string, layer: WalletGlowLayerSettings) => ({ [`--${prefix}-blur`]: `${layer.blur}px`, [`--${prefix}-intensity`]: `${layer.intensity / 100}`, [`--${prefix}-spread`]: `${layer.spread / 100}`, [`--${prefix}-x`]: `${layer.x}px`, [`--${prefix}-y`]: `${layer.y}px`, }); return { ...layerVars('wallet-coin-shadow', settings.coinShadow), '--wallet-coin-shadow-blur-effective': `${ (settings.coinShadow.blur * settings.coinShadow.spread) / 100 }px`, '--wallet-coin-shadow-x-effective': `${ (settings.coinShadow.x * settings.coinShadow.spread) / 100 }px`, '--wallet-coin-shadow-y-effective': `${ (settings.coinShadow.y * settings.coinShadow.spread) / 100 }px`, ...layerVars('wallet-floor-reflection', settings.floorReflection), ...layerVars('wallet-floor-shadow', settings.floorShadow), } as Record; }; const WALLET_GLOW_CSS_VARS = createWalletGlowCssVars( DEFAULT_WALLET_GLOW_SETTINGS ); const RECEIVE_QR_SLOT_HEIGHT = 404; const walletOuterSurfaceSx = { backgroundColor: (t: Theme) => t.palette.mode === 'dark' ? 'rgba(8, 32, 50, 0.66)' : t.palette.background.paper, backgroundImage: (t: Theme) => t.palette.mode === 'dark' ? 'linear-gradient(180deg, rgba(13, 48, 72, 0.54) 0%, rgba(7, 28, 45, 0.58) 100%)' : 'none', } as const; const walletInnerSurfaceSx = { bgcolor: (t: Theme) => t.palette.mode === 'dark' ? 'rgba(17, 60, 86, 0.34)' : 'background.paper', } as const; const WALLET_BALANCE_DISPLAY_LIMIT = '999,999.99'.length; const formatWalletDisplayAmount = ( value: unknown, symbol: WalletCoinSymbol, decimals = WALLET_VISUALS[symbol].decimals ) => { if (value === null || value === undefined || value === '') return `0 ${symbol}`; const numeric = Number(value); if (!Number.isFinite(numeric)) return `${String(value)} ${symbol}`; const formatter = (maximumFractionDigits: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits, minimumFractionDigits: 0, }).format(numeric); let maximumFractionDigits = decimals; let formatted = formatter(maximumFractionDigits); while ( formatted.length > WALLET_BALANCE_DISPLAY_LIMIT && maximumFractionDigits > 0 ) { maximumFractionDigits -= 1; formatted = formatter(maximumFractionDigits); } return `${formatted} ${symbol}`; }; function ReceiveActionIcon({ open }: { open: boolean }) { const transition = 'opacity 260ms ease, transform 520ms cubic-bezier(0.16, 1, 0.3, 1)'; return ( ); } function ReceiveActionLabel({ hideReceiveLabel, open, receiveLabel, }: { hideReceiveLabel: string; open: boolean; receiveLabel: string; }) { const labelSx = (visible: boolean, direction: number) => ({ gridArea: '1 / 1', opacity: visible ? 1 : 0, transform: visible ? 'translateY(0) scale(1)' : `translateY(${direction * 8}px) scale(0.94)`, transition: 'opacity 240ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1)', whiteSpace: 'nowrap', }) as const; return ( ); } function ReceiveQrMotionContent({ address, coin, onQrClick, open, }: ReceiveQrPanelProps & { open: boolean }) { const [entered, setEntered] = useState(false); useEffect(() => { if (!open) { setEntered(false); return undefined; } const frameId = requestAnimationFrame(() => setEntered(true)); return () => { cancelAnimationFrame(frameId); }; }, [open]); const visible = open && entered; return ( ); } type ReceiveQrDialogProps = { address?: string | null; coin: WalletCoinSymbol; onClose: () => void; open: boolean; }; function ReceiveQrDialog({ address, coin, onClose, open, }: ReceiveQrDialogProps) { const { t } = useTranslation(['core']); const visual = WALLET_VISUALS[coin]; const value = address ?? ''; return ( t.palette.mode === 'dark' ? 'rgba(0, 7, 12, 0.68)' : 'rgba(15, 23, 42, 0.32)', }, }, paper: { sx: { ...getWalletVars(visual), backgroundColor: (t: Theme) => t.palette.mode === 'dark' ? 'rgba(3, 17, 29, 0.985)' : '#ffffff', backgroundImage: (t: Theme) => t.palette.mode === 'dark' ? 'radial-gradient(circle at 16% 8%, color-mix(in srgb, var(--wallet-accent) 16%, transparent), transparent 34%), linear-gradient(180deg, rgba(5,24,39,0.99) 0%, rgba(3,13,23,0.995) 100%)' : 'radial-gradient(circle at 16% 8%, color-mix(in srgb, var(--wallet-accent) 10%, transparent), transparent 34%), linear-gradient(180deg, rgba(255,255,255,0.99) 0%, rgba(246,250,252,0.995) 100%)', border: (t: Theme) => t.palette.mode === 'dark' ? '1px solid rgba(91,132,158,0.28)' : '1px solid rgba(11,143,211,0.16)', borderRadius: 2, boxShadow: (t: Theme) => t.palette.mode === 'dark' ? '0 28px 72px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.04)' : '0 24px 70px rgba(15,74,106,0.18), inset 0 1px 0 rgba(255,255,255,0.9)', color: 'text.primary', overflow: 'hidden', width: 'min(386px, calc(100vw - 28px))', }, }, }} > {t('core:wallet.receive_symbol', { symbol: visual.symbol })} {visual.name} address t.palette.mode === 'dark' ? 'rgba(3, 16, 27, 0.64)' : 'rgba(246,250,252,0.82)', border: (t: Theme) => t.palette.mode === 'dark' ? '1px solid rgba(116,158,180,0.16)' : '1px solid rgba(11,143,211,0.16)', borderRadius: 1, display: 'flex', gap: 1, minHeight: 46, px: 1.2, width: '100%', }} > {value || t('core:wallet.no_address_available')} copyToClipboard(value)} size="small" sx={{ color: 'text.secondary', ml: 'auto', '&:hover': { color: 'var(--wallet-accent)' }, }} > ); } const toFiniteWalletNumber = (value: unknown) => { const parsed = typeof value === 'number' ? value : Number.parseFloat(String(value ?? 0)); return Number.isFinite(parsed) ? parsed : null; }; const useChargingBalance = ( value: unknown, isLoading?: boolean, balanceError?: string | null ) => { const [animatedValue, setAnimatedValue] = useState(null); const previousTargetRef = useRef(null); useEffect(() => { const target = toFiniteWalletNumber(value); if (isLoading || balanceError || target === null) { previousTargetRef.current = null; setAnimatedValue(null); return; } const previousTarget = previousTargetRef.current; previousTargetRef.current = target; if (previousTarget === target) { setAnimatedValue(target); return; } const absTarget = Math.abs(target); const chargeOffset = absTarget > 0 ? Math.max(absTarget * 0.035, Math.min(absTarget, 0.01)) : 0; const startValue = target >= 0 ? Math.max(0, target - chargeOffset) : target; const duration = 720; const startedAt = performance.now(); let frameId = 0; const tick = (now: number) => { const progress = Math.min(1, (now - startedAt) / duration); const eased = 1 - Math.pow(1 - progress, 3); setAnimatedValue(startValue + (target - startValue) * eased); if (progress < 1) { frameId = requestAnimationFrame(tick); } }; setAnimatedValue(startValue); frameId = requestAnimationFrame(tick); return () => cancelAnimationFrame(frameId); }, [balanceError, isLoading, value]); return animatedValue; }; type WalletSummaryCardProps = { address?: string | null; balance?: unknown; balanceDecimals?: number; balanceError?: string | null; coin: WalletCoinSymbol; copyAddressLabel?: string; hideReceiveLabel?: string; isBalanceLoading?: boolean; noAddressLabel?: string; onSend: () => void; onToggleReceive: () => void; receiveLabel?: string; receiveOpen: boolean; sendLabel?: string; }; export function WalletSummaryCard({ address, balance, balanceDecimals, balanceError, coin, copyAddressLabel, hideReceiveLabel, isBalanceLoading, noAddressLabel, onSend, onToggleReceive, receiveLabel, receiveOpen, sendLabel, }: WalletSummaryCardProps) { const { t } = useTranslation(['core']); const visual = WALLET_VISUALS[coin]; const displayCopyAddressLabel = copyAddressLabel ?? t('core:action.copy_address', { postProcess: 'capitalizeFirstChar' }); const displayHideReceiveLabel = hideReceiveLabel ?? t('core:wallet.hide_qr'); const displayNoAddressLabel = noAddressLabel ?? t('core:wallet.no_address_available'); const displayReceiveLabel = receiveLabel ?? t('core:wallet.receive', { postProcess: 'capitalizeFirstChar' }); const displaySendLabel = sendLabel ?? t('core:action.send', { postProcess: 'capitalizeFirstChar' }); const addressLabel = address || displayNoAddressLabel; const animatedBalance = useChargingBalance( balance, isBalanceLoading, balanceError ); const stableFormattedBalance = formatWalletDisplayAmount( balance, visual.symbol, balanceDecimals ); const displayFormattedBalance = formatWalletDisplayAmount( animatedBalance ?? balance, visual.symbol, balanceDecimals ); const balanceAmount = displayFormattedBalance.endsWith(` ${visual.symbol}`) ? displayFormattedBalance.slice(0, -(visual.symbol.length + 1)) : displayFormattedBalance; const balanceFitUnits = Math.max(5.6, stableFormattedBalance.length * 0.62); const balanceFontSize = { xs: `min(clamp(2rem, 12vw, 3rem), calc(100cqw / ${balanceFitUnits}))`, md: `min(clamp(2.45rem, 4vw, 3.35rem), calc(100cqw / ${balanceFitUnits}))`, }; return ( ); } type ReceiveQrPanelProps = { address?: string | null; coin: WalletCoinSymbol; copyLabel?: string; downloadLabel?: string; onQrClick?: () => void; }; export function ReceiveQrPanel({ address, coin, downloadLabel = 'Download', onQrClick, }: ReceiveQrPanelProps) { const visual = WALLET_VISUALS[coin]; const qrRef = useRef(null); const value = address ?? ''; const handleDownload = () => { const svg = qrRef.current?.querySelector('svg'); if (!svg) return; const clonedSvg = svg.cloneNode(true) as SVGElement; clonedSvg.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); const svgData = new XMLSerializer().serializeToString(clonedSvg); const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${visual.symbol.toLowerCase()}-receive-qr.svg`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); }; return ( Receive {visual.symbol} Scan to receive ); } type WalletAddressBookPanelProps = { coin: WalletCoinSymbol; onAddContact: () => void; onAddressBookChange?: () => void; onSelectAddress: (address: string, name: string) => void; refreshKey?: unknown; }; export function WalletAddressBookPanel({ coin, onAddContact, onAddressBookChange, onSelectAddress, refreshKey, }: WalletAddressBookPanelProps) { const { t } = useTranslation(['core']); const visual = WALLET_VISUALS[coin]; const [entries, setEntries] = useState([]); const [search, setSearch] = useState(''); const [draggedEntryId, setDraggedEntryId] = useState(null); const [dragOverEntryId, setDragOverEntryId] = useState(null); const [editingEntry, setEditingEntry] = useState< AddressBookEntry | undefined >(undefined); const [deletingEntry, setDeletingEntry] = useState< AddressBookEntry | undefined >(undefined); const [editFormOpen, setEditFormOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [editSaveError, setEditSaveError] = useState(''); const [suppressSelect, setSuppressSelect] = useState(false); useEffect(() => { setEntries(getAddressBook(visual.coinType)); }, [refreshKey, visual.coinType]); useEffect(() => { const handleAddressBookStorage = () => { setEntries(getAddressBook(visual.coinType)); }; window.addEventListener( ADDRESS_BOOK_STORAGE_EVENT, handleAddressBookStorage ); return () => { window.removeEventListener( ADDRESS_BOOK_STORAGE_EVENT, handleAddressBookStorage ); }; }, [visual.coinType]); const visibleEntries = useMemo(() => { const query = search.trim().toLowerCase(); if (!query) return entries; return entries.filter( (entry) => entry.name.toLowerCase().includes(query) || entry.address.toLowerCase().includes(query) || entry.note.toLowerCase().includes(query) ); }, [entries, search]); const maxVisibleContacts = 12; const displayedEntries = visibleEntries.slice(0, maxVisibleContacts); const hasMoreContacts = visibleEntries.length > maxVisibleContacts; const reloadEntries = () => { const nextEntries = getAddressBook(visual.coinType); setEntries(nextEntries); return nextEntries; }; const handleToggleFavorite = ( event: MouseEvent, entry: AddressBookEntry ) => { event.stopPropagation(); event.currentTarget.blur(); const updatedEntries = toggleAddressBookFavorite(entry.id, visual.coinType); if (updatedEntries) { reloadEntries(); onAddressBookChange?.(); } }; const handleEditContact = ( event: MouseEvent, entry: AddressBookEntry ) => { event.stopPropagation(); event.currentTarget.blur(); setEditSaveError(''); setEditingEntry(entry); setEditFormOpen(true); }; const handleEditFormClose = () => { setEditFormOpen(false); }; const handleEditFormExited = () => { setEditingEntry(undefined); setEditSaveError(''); }; const handleDeleteClick = (entry: AddressBookEntry) => { setDeletingEntry(entry); setDeleteConfirmOpen(true); }; const handleDeleteCancel = () => { setDeleteConfirmOpen(false); setDeletingEntry(undefined); }; const handleDeleteConfirm = () => { if (!deletingEntry) return; const deleted = deleteAddress(deletingEntry.id, visual.coinType); if (deleted) { reloadEntries(); onAddressBookChange?.(); if (editingEntry?.id === deletingEntry.id) { setEditFormOpen(false); } } setDeleteConfirmOpen(false); setDeletingEntry(undefined); }; const handleEditSave = ( entry: Omit ) => { if (!editingEntry) return; try { const savedEntry = updateAddress(editingEntry.id, visual.coinType, { name: entry.name, address: entry.address, note: entry.note, }); if (!savedEntry) { throw new Error('Could not save contact.'); } reloadEntries(); setEditFormOpen(false); setEditSaveError(''); onAddressBookChange?.(); } catch (error: any) { console.error('Error saving address:', error); setEditSaveError( error?.message || 'Could not save contact. Please try again.' ); } }; const handleReorder = (sourceId: string, targetId: string) => { const updatedEntries = moveAddressBookEntry( visual.coinType, sourceId, targetId ); if (updatedEntries) { reloadEntries(); onAddressBookChange?.(); } }; const handleDragStart = ( event: DragEvent, entry: AddressBookEntry ) => { setDraggedEntryId(entry.id); event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', entry.id); }; const handleDragOver = ( event: DragEvent, entry: AddressBookEntry ) => { if (!draggedEntryId || draggedEntryId === entry.id) return; event.preventDefault(); event.dataTransfer.dropEffect = 'move'; setDragOverEntryId(entry.id); }; const handleDrop = ( event: DragEvent, entry: AddressBookEntry ) => { event.preventDefault(); const sourceId = draggedEntryId || event.dataTransfer.getData('text/plain'); setDraggedEntryId(null); setDragOverEntryId(null); if (!sourceId || sourceId === entry.id) return; setSuppressSelect(true); handleReorder(sourceId, entry.id); window.setTimeout(() => setSuppressSelect(false), 0); }; const handleDragEnd = () => { setDraggedEntryId(null); setDragOverEntryId(null); }; return ( <> t.palette.mode === 'dark' ? 'linear-gradient(180deg, rgba(10, 34, 52, 0.82) 0%, rgba(7, 27, 43, 0.76) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(247,251,253,0.9) 100%)', borderColor: (t) => t.palette.mode === 'dark' ? 'rgba(116,158,180,0.14)' : 'rgba(11,143,211,0.14)', boxShadow: (t) => t.palette.mode === 'dark' ? 'inset 0 1px 0 rgba(255,255,255,0.04)' : '0 18px 48px rgba(15, 74, 106, 0.08), inset 0 1px 0 rgba(255,255,255,0.82)', overflow: 'hidden', width: '100%', }} > {t('core:wallet.address_book_for_symbol', { symbol: visual.symbol, })} setSearch(event.target.value)} InputProps={{ startAdornment: ( ), }} sx={{ mb: 1.1, '& .MuiInputBase-input': { fontSize: 13, lineHeight: '18px', py: 0.65, }, '& .MuiOutlinedInput-root': { bgcolor: (t) => t.palette.mode === 'dark' ? 'rgba(1, 12, 24, 0.34)' : 'rgba(255,255,255,0.78)', minHeight: 32, '& fieldset': { borderColor: (t) => t.palette.mode === 'dark' ? 'rgba(116,158,180,0.18)' : 'rgba(17,24,39,0.08)', }, '&:hover fieldset': { borderColor: 'primary.main' }, }, }} /> {displayedEntries.length > 0 ? ( displayedEntries.map((entry, index) => { const initials = entry.name .split(' ') .filter(Boolean) .map((part) => part[0]) .join('') .slice(0, 2) .toUpperCase() || visual.symbol[0]; const avatarColor = getAddressBookAvatarColor( `${entry.name}-${entry.address}`, index ); return ( ) => { if (suppressSelect) return; event.currentTarget.blur(); onSelectAddress(entry.address, entry.name); }} onDragStart={(event) => handleDragStart(event, entry)} onDragOver={(event) => handleDragOver(event, entry)} onDrop={(event) => handleDrop(event, entry)} onDragEnd={handleDragEnd} onKeyDown={(event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onSelectAddress(entry.address, entry.name); } }} sx={{ alignItems: 'center', bgcolor: (t) => t.palette.mode === 'dark' ? 'rgba(6, 25, 40, 0.22)' : 'rgba(255,255,255,0.62)', border: (t) => t.palette.mode === 'dark' ? '1px solid rgba(116,158,180,0.075)' : '1px solid rgba(11,143,211,0.1)', borderRadius: 1, cursor: 'grab', display: 'grid', gap: 0.85, gridTemplateColumns: '38px minmax(0, 1fr) 104px', minHeight: 46, opacity: draggedEntryId === entry.id ? 0.52 : 1, px: 1, py: 0.55, transition: 'background-color 150ms ease, border-color 150ms ease, opacity 150ms ease', ...(dragOverEntryId === entry.id && { bgcolor: 'rgba(24,189,242,0.08)', borderColor: 'rgba(24,189,242,0.28)', }), '&:hover': { bgcolor: (t) => t.palette.mode === 'dark' ? 'rgba(14, 49, 72, 0.3)' : 'rgba(239,248,252,0.95)', borderColor: (t) => t.palette.mode === 'dark' ? 'rgba(116,158,180,0.13)' : 'rgba(11,143,211,0.18)', }, '&:hover .contact-action, &:focus-within .contact-action': { opacity: 1, pointerEvents: 'auto', transform: 'translateX(0)', }, '&:hover .contact-action-star, &:focus-within .contact-action-star': { right: 72, }, '&:focus-visible': { borderColor: 'color-mix(in srgb, var(--wallet-accent, #18bdf2) 44%, transparent)', outline: 'none', }, }} > {initials} {entry.note && ( {entry.note} )} ) => { event.stopPropagation(); event.currentTarget.blur(); copyToClipboard(entry.address); }} sx={{ bgcolor: 'transparent', border: '1px solid rgba(116,158,180,0.16)', borderRadius: 1, boxShadow: 'none', color: 'text.secondary', height: 32, opacity: 0, overflow: 'hidden', pointerEvents: 'none', position: 'absolute', right: 36, transform: 'translateX(10px)', transition: 'opacity 180ms ease, transform 240ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, border-color 150ms ease, background-color 150ms ease', width: 32, '& .MuiTouchRipple-root': { display: 'none' }, '&:hover': { bgcolor: 'rgba(116,158,180,0.08)', borderColor: 'rgba(116,158,180,0.26)', boxShadow: 'none', color: 'text.primary', }, }} > ) => handleToggleFavorite(event, entry) } sx={{ bgcolor: 'transparent', border: entry.favorite ? 0 : '1px solid rgba(116,158,180,0.16)', borderRadius: 1, boxShadow: 'none', color: entry.favorite ? '#f6c84c' : 'text.secondary', height: 32, opacity: entry.favorite ? 1 : 0, overflow: 'hidden', pointerEvents: entry.favorite ? 'auto' : 'none', position: 'absolute', right: entry.favorite ? 0 : 72, transform: entry.favorite ? 'translateX(0)' : 'translateX(10px)', transition: 'opacity 180ms ease, transform 260ms cubic-bezier(0.16, 1, 0.3, 1), right 260ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, border-color 150ms ease, background-color 150ms ease', width: 32, '& .MuiTouchRipple-root': { display: 'none' }, '&:hover': { bgcolor: 'rgba(246,200,76,0.08)', borderColor: 'rgba(246,200,76,0.28)', boxShadow: 'none', color: '#ffd76a', }, }} > {entry.favorite ? ( ) : ( )} ) => handleEditContact(event, entry) } sx={{ bgcolor: 'transparent', border: '1px solid rgba(116,158,180,0.16)', borderRadius: 1, boxShadow: 'none', color: 'text.secondary', height: 32, opacity: 0, overflow: 'hidden', pointerEvents: 'none', position: 'absolute', right: 0, transform: 'translateX(10px)', transition: 'opacity 180ms ease, transform 240ms cubic-bezier(0.16, 1, 0.3, 1), color 150ms ease, border-color 150ms ease, background-color 150ms ease', width: 32, '& .MuiTouchRipple-root': { display: 'none' }, '&:hover': { bgcolor: 'rgba(116,158,180,0.08)', borderColor: 'rgba(116,158,180,0.26)', boxShadow: 'none', color: 'text.primary', }, }} > ); }) ) : ( {search.trim() ? 'No matching contacts found' : `No ${visual.symbol} contacts yet`} {search.trim() ? 'Try a different name, address or note.' : `Add contacts to your address book to send ${visual.symbol} faster and avoid mistakes.`} {!search.trim() && ( )} )} {hasMoreContacts && ( )} ); } type WalletTransactionsCardProps = { actions?: ReactNode; children: ReactNode; isRefreshing?: boolean; onRefresh?: () => void; title?: string; }; type WalletTransactionsLoaderProps = { label?: string; }; export function WalletTransactionsLoader({ label, }: WalletTransactionsLoaderProps) { const { t } = useTranslation(['core']); const displayLabel = label ?? t('core:wallet.loading_transactions'); return ( {[0, 1, 2].map((index) => ( ))} {displayLabel} ); } type WalletExternalTransactionEntry = { address?: string; addressInWallet?: boolean; amount?: number; }; export type WalletExternalTransactionRow = { feeAmount?: unknown; inputs?: WalletExternalTransactionEntry[]; memo?: string; outputs?: WalletExternalTransactionEntry[]; timestamp?: number; totalAmount?: unknown; txHash?: string; }; type WalletExternalTransactionsListLabels = { copyHash: (hash: string) => string; fee: string; memo?: string; noTransactions: string; receiver: string; rowsPerPage: string; sender: string; time: string; totalAmount: string; transactionHash: string; waitingConfirmation: string; }; type WalletExternalTransactionsListProps = { ActionsComponent: any; coin: WalletCoinSymbol; copyHashLabel?: string; labels: WalletExternalTransactionsListLabels; onCopyHash: (hash: string) => void; onPageChange: ( event: MouseEvent | null, newPage: number ) => void; onRowsPerPageChange: ( event: ChangeEvent ) => void; page: number; rows: WalletExternalTransactionRow[]; rowsPerPage: number; showMemo?: boolean; }; const externalTransactionHeaderSx = { color: 'text.secondary', fontSize: 11, fontWeight: 700, letterSpacing: 0, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', textTransform: 'uppercase', whiteSpace: 'nowrap', }; const walletTableRowHoverSx = { overflow: 'hidden', position: 'relative', '&::before': { background: 'linear-gradient(90deg, rgba(24,189,242,0.07), rgba(24,189,242,0.025))', content: '""', inset: 0, opacity: 0, pointerEvents: 'none', position: 'absolute', transition: 'opacity 520ms ease-out', zIndex: 0, }, '&:hover::before': { opacity: 1, transitionDuration: '90ms', }, '& > *': { position: 'relative', zIndex: 1, }, } as const; const formatExternalTransactionAmount = (value: unknown) => { const numeric = Number(value); if (!Number.isFinite(numeric)) return '-'; return (numeric / 1e8).toFixed(8); }; export function WalletExternalTransactionsList({ ActionsComponent, coin, copyHashLabel, labels, onCopyHash, onPageChange, onRowsPerPageChange, page, rows, rowsPerPage, showMemo, }: WalletExternalTransactionsListProps) { const [copiedAddress, setCopiedAddress] = useState(null); const gridColumns = showMemo ? 'minmax(120px, 1fr) minmax(120px, 1fr) minmax(120px, 0.9fr) minmax(88px, 0.7fr) minmax(118px, 0.8fr) minmax(78px, 0.55fr) minmax(92px, 0.65fr)' : 'minmax(130px, 1fr) minmax(130px, 1fr) minmax(128px, 0.9fr) minmax(118px, 0.78fr) minmax(78px, 0.55fr) minmax(92px, 0.65fr)'; const minWidth = showMemo ? 880 : 760; const pagedRows = rowsPerPage > 0 ? rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) : rows; const handleCopyAddress = (address: string) => { copyToClipboard(address); setCopiedAddress(address); window.setTimeout(() => { setCopiedAddress((currentAddress) => currentAddress === address ? null : currentAddress ); }, 1200); }; const renderEndpoint = (entries?: WalletExternalTransactionEntry[]) => { const addressEntries = entries?.filter((entry) => entry.address) ?? []; if (!addressEntries.length) { return ( - ); } return ( {addressEntries.map((entry, index) => ( {entry.address} handleCopyAddress(entry.address ?? '')} size="small" sx={{ color: 'text.secondary', mt: -0.45, p: 0.25, }} > ))} ); }; const renderAmount = (row: WalletExternalTransactionRow) => { const numeric = Number(row.totalAmount); if (!Number.isFinite(numeric)) return '-'; const isIncoming = numeric > 0; return ( {isIncoming ? '+' : ''} {formatExternalTransactionAmount(row.totalAmount)} {coin} ); }; const renderFee = (row: WalletExternalTransactionRow) => { const numericFee = Number(row.feeAmount); if (!Number.isFinite(numericFee) || numericFee === 0) return '-'; return ( -{formatExternalTransactionAmount(row.feeAmount)} ); }; const renderHash = (hash: string) => ( {hash ? cropString(hash) : '-'} {hash && ( onCopyHash(hash)} sx={{ color: 'text.secondary', p: 0.25 }} > )} ); const renderMobileField = (label: string, content: ReactNode) => ( {label} {content} ); if (!rows.length) { return ( `1px solid ${t.palette.divider}`, borderTop: (t) => `1px solid ${t.palette.divider}`, color: 'text.secondary', px: 2, py: 4, textAlign: 'center', }} > {labels.noTransactions} ); } return ( <> {pagedRows.map((row, index) => { const hash = row.txHash ?? ''; return ( `1px solid ${ t.palette.mode === 'dark' ? 'rgba(116,158,180,0.11)' : 'rgba(17,24,39,0.08)' }`, borderRadius: 1, display: 'grid', gap: 1.1, minWidth: 0, p: 1.15, }} > {renderMobileField(labels.transactionHash, renderHash(hash))} {renderAmount(row)} {renderMobileField(labels.sender, renderEndpoint(row.inputs))} {renderMobileField( labels.receiver, renderEndpoint(row.outputs) )} {showMemo && ( {renderMobileField( labels.memo ?? 'Memo', {row.memo || '-'} )} )} {renderMobileField(labels.fee, renderFee(row))} {renderMobileField( labels.time, {row.timestamp ? epochToAgo(row.timestamp) : '-'} )} ); })} `1px solid ${t.palette.divider}`, display: 'grid', gap: 1, gridTemplateColumns: gridColumns, px: 1.25, py: 1, }} > {labels.sender} {labels.receiver} {labels.transactionHash} {showMemo && ( {labels.memo} )} {labels.totalAmount} {labels.fee} {labels.time} {pagedRows.map((row, index) => { const hash = row.txHash ?? ''; return ( `1px solid ${ t.palette.mode === 'dark' ? 'rgba(116,158,180,0.085)' : 'rgba(17,24,39,0.06)' }`, display: 'grid', gap: 1, gridTemplateColumns: gridColumns, minHeight: 46, px: 1.25, py: 0.85, transition: 'background-color 150ms ease, border-color 150ms ease', }} > {renderEndpoint(row.inputs)} {renderEndpoint(row.outputs)} {renderHash(hash)} {showMemo && ( {row.memo || '-'} )} {renderAmount(row)} {renderFee(row)} {row.timestamp ? epochToAgo(row.timestamp) : '-'} ); })} ); } export function WalletTransactionsCard({ actions, children, isRefreshing, onRefresh, title, }: WalletTransactionsCardProps) { const { t } = useTranslation(['core']); const displayTitle = title ?? t('core:wallet.transactions'); return ( t.palette.mode === 'dark' ? 'linear-gradient(180deg, rgba(10, 36, 56, 0.74) 0%, rgba(7, 29, 47, 0.68) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.94) 0%, rgba(247,251,253,0.92) 100%)', borderColor: (t) => t.palette.mode === 'dark' ? 'rgba(116,158,180,0.15)' : 'rgba(11,143,211,0.13)', boxShadow: (t) => t.palette.mode === 'dark' ? 'inset 0 1px 0 rgba(255,255,255,0.045)' : '0 18px 54px rgba(15, 74, 106, 0.08), inset 0 1px 0 rgba(255,255,255,0.84)', minWidth: 0, overflow: 'hidden', width: '100%', '& .MuiTableContainer-root': { bgcolor: 'transparent', border: 0, borderRadius: 0, boxShadow: 'none', overflowX: 'hidden', }, '& .MuiTable-root': { tableLayout: 'fixed', width: '100%', }, '& .MuiTableCell-root': { minWidth: 0, }, '& .MuiTableCell-root:nth-of-type(1), & .MuiTableCell-root:nth-of-type(2)': { width: '18%', }, '& .MuiTableCell-root:nth-of-type(3)': { width: '16%', }, '& .MuiTableCell-root:nth-of-type(4)': { width: '12%', }, '& .MuiTableCell-root:nth-of-type(5)': { width: '10%', }, '& .MuiTableCell-root:nth-of-type(6)': { width: '10%', }, '& .MuiTableCell-root:nth-of-type(7)': { width: '10%', }, '& .MuiTableCell-head': { bgcolor: 'transparent', color: 'text.secondary', fontSize: 11, fontWeight: 600, letterSpacing: 0, overflow: 'hidden', px: 1.5, py: 1.2, textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, '& .MuiTableCell-body': { bgcolor: 'transparent', fontSize: 13, height: 46, lineHeight: 1.25, overflow: 'hidden', px: 1.5, py: 0.85, textOverflow: 'ellipsis', verticalAlign: 'middle', whiteSpace: 'nowrap', }, '& .MuiTableBody-root .MuiTableRow-root': { transition: 'background-color 150ms ease', }, '& .MuiTableBody-root .MuiTableRow-root:hover': { bgcolor: (t) => t.palette.mode === 'dark' ? 'rgba(24,189,242,0.055)' : 'rgba(5,127,168,0.05)', }, '& .MuiTableBody-root .MuiTableCell-root:nth-of-type(1) > .MuiBox-root, & .MuiTableBody-root .MuiTableCell-root:nth-of-type(2) > .MuiBox-root': { display: 'grid !important', gridTemplateColumns: 'minmax(0, 1fr)', minWidth: 0, }, '& .MuiTableBody-root .MuiTableCell-root:nth-of-type(1) > .MuiBox-root:not(:first-of-type), & .MuiTableBody-root .MuiTableCell-root:nth-of-type(2) > .MuiBox-root:not(:first-of-type)': { display: 'none !important', }, '& .MuiTableBody-root .MuiTableCell-root:nth-of-type(1) span, & .MuiTableBody-root .MuiTableCell-root:nth-of-type(2) span': { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, '& .MuiTableBody-root .MuiTableCell-root:nth-of-type(1) span + span, & .MuiTableBody-root .MuiTableCell-root:nth-of-type(2) span + span': { display: 'none', }, '& .MuiTableBody-root .MuiTableCell-root:nth-of-type(3) .MuiIconButton-root': { ml: 0.25, p: 0.25, }, '& .MuiTableFooter-root .MuiTableCell-root': { borderBottom: 0, px: 0, }, '& .MuiTablePagination-root': { color: 'text.secondary', }, '& .MuiTablePagination-toolbar': { minHeight: 44, px: 0, }, '& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': { color: 'text.secondary', fontSize: 13, }, }} > {displayTitle} {(actions || onRefresh) && ( {actions} {onRefresh && ( )} )} t.palette.mode === 'dark' ? 'linear-gradient(180deg, rgba(18, 62, 89, 0.36) 0%, rgba(12, 47, 72, 0.32) 100%)' : 'linear-gradient(180deg, rgba(242,248,251,0.72) 0%, rgba(255,255,255,0.5) 100%)', borderTop: (t) => t.palette.mode === 'dark' ? '1px solid rgba(116,158,180,0.11)' : '1px solid rgba(11,143,211,0.1)', boxShadow: (t) => t.palette.mode === 'dark' ? 'inset 0 1px 0 rgba(255,255,255,0.028)' : 'inset 0 1px 0 rgba(255,255,255,0.72)', minWidth: 0, overflow: 'hidden', }} > {children} ); } type WalletSyncCardProps = { isSyncing?: boolean; onSync: () => void; statusLabel: string; statusTone?: 'success' | 'error'; statusTooltip?: string; }; export function WalletSyncCard({ isSyncing, onSync, statusLabel, statusTone = 'success', statusTooltip, }: WalletSyncCardProps) { const { t } = useTranslation(['core']); const isError = statusTone === 'error'; const syncDescription = t('core:wallet.sync_description'); return ( t.palette.mode === 'dark' ? 'linear-gradient(180deg, rgba(10, 34, 52, 0.82) 0%, rgba(7, 27, 43, 0.76) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(247,251,253,0.9) 100%)', borderColor: (t) => t.palette.mode === 'dark' ? 'rgba(116,158,180,0.14)' : 'rgba(11,143,211,0.14)', boxShadow: (t) => t.palette.mode === 'dark' ? 'inset 0 1px 0 rgba(255,255,255,0.04)' : '0 18px 48px rgba(15, 74, 106, 0.08), inset 0 1px 0 rgba(255,255,255,0.82)', overflow: 'hidden', width: '100%', }} > t.palette.mode === 'dark' ? 'rgba(1, 12, 24, 0.34)' : 'rgba(239,248,252,0.88)', border: '1px solid rgba(116,158,180,0.16)', borderRadius: 1, color: isError ? 'rgba(246, 196, 78, 0.92)' : 'rgba(34,227,138,0.72)', display: 'inline-flex', flexShrink: 0, height: 32, justifyContent: 'center', width: 32, }} > {t('core:wallet.encrypted_sync')} {t('core:wallet.local_address_book')} {isError ? ( ) : ( )} {statusLabel} ); } type WalletWorkspaceProps = { address?: string | null; addressBookRefreshKey?: unknown; balance?: unknown; balanceDecimals?: number; balanceError?: string | null; children?: ReactNode; coin: WalletCoinSymbol; isBalanceLoading?: boolean; noAddressLabel?: string; onAddContact: () => void; onAddressBookChange?: () => void; onSelectAddress: (address: string, name: string) => void; onSend: () => void; onToggleReceive: () => void; receiveOpen: boolean; rightColumnAfter?: ReactNode; transactions: ReactNode; }; export function WalletWorkspace({ address, addressBookRefreshKey, balance, balanceDecimals, balanceError, children, coin, isBalanceLoading, noAddressLabel, onAddContact, onAddressBookChange, onSelectAddress, onSend, onToggleReceive, receiveOpen, rightColumnAfter, transactions, }: WalletWorkspaceProps) { const visual = WALLET_VISUALS[coin]; const [receiveQrDialogOpen, setReceiveQrDialogOpen] = useState(false); // The wallet summary keeps its natural width; the Address book / Sync panels // are only shown when they actually fit beside it. When the content would // overlap them, they disappear and the main column takes the full width. const workspaceRef = useRef(null); const mainColumnRef = useRef(null); const requiredWidthRef = useRef(0); const showSidePanelsRef = useRef(true); const [showSidePanels, setShowSidePanels] = useState(true); useLayoutEffect(() => { const workspace = workspaceRef.current; const mainColumn = mainColumnRef.current; if (!workspace || !mainColumn) return; const RIGHT_COLUMN_WIDTH = 424; const COLUMN_GAP = 24; const measure = () => { const available = workspace.clientWidth; // While the panels are shown the main column is constrained to its track. // If its content (the wallet summary) is wider than that track it overflows, // and scrollWidth then reveals how much total width the layout would need to // also fit the side panels. (Only sampled while shown — when hidden the main // column spans the full width and would no longer overflow.) if ( showSidePanelsRef.current && mainColumn.scrollWidth > mainColumn.clientWidth + 1 ) { requiredWidthRef.current = mainColumn.scrollWidth + RIGHT_COLUMN_WIDTH + COLUMN_GAP; } const required = requiredWidthRef.current; const next = required === 0 ? true : available >= required; showSidePanelsRef.current = next; setShowSidePanels(next); }; measure(); const observer = new ResizeObserver(measure); observer.observe(workspace); observer.observe(mainColumn); return () => observer.disconnect(); }, []); useEffect(() => { if (!receiveOpen) { setReceiveQrDialogOpen(false); } }, [receiveOpen]); return ( ); }