Files

3410 lines
106 KiB
TypeScript

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<WalletCoinSymbol, WalletVisual> = {
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<string, string>;
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<string, string>;
};
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 (
<Box
aria-hidden="true"
component="span"
sx={{
alignItems: 'center',
display: 'inline-grid',
height: 20,
justifyItems: 'center',
position: 'relative',
width: 20,
'& svg': {
fontSize: 20,
gridArea: '1 / 1',
position: 'absolute',
transformOrigin: 'center',
transition,
},
}}
>
<SouthWest
sx={{
opacity: open ? 0 : 1,
transform: open
? 'rotate(225deg) scale(0.42)'
: 'rotate(0deg) scale(1)',
}}
/>
<Close
sx={{
opacity: open ? 1 : 0,
transform: open
? 'rotate(0deg) scale(1)'
: 'rotate(-225deg) scale(0.42)',
}}
/>
</Box>
);
}
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 (
<Box
aria-hidden="true"
component="span"
sx={{
alignItems: 'center',
display: 'inline-grid',
justifyItems: 'center',
minWidth: '7ch',
}}
>
<Box component="span" sx={labelSx(!open, -1)}>
{receiveLabel}
</Box>
<Box component="span" sx={labelSx(open, 1)}>
{hideReceiveLabel}
</Box>
</Box>
);
}
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 (
<Box
sx={{
opacity: visible ? 1 : 0,
transformOrigin: 'top center',
transition: open ? 'opacity 260ms ease-out' : 'opacity 320ms ease-in',
willChange: 'opacity',
}}
>
<ReceiveQrPanel address={address} coin={coin} onQrClick={onQrClick} />
</Box>
);
}
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 (
<Dialog
disableScrollLock
disableAutoFocus
disableRestoreFocus
fullWidth
maxWidth={false}
onClose={onClose}
open={open}
slotProps={{
backdrop: {
sx: {
backdropFilter: 'blur(8px)',
backgroundColor: (t: Theme) =>
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))',
},
},
}}
>
<DialogTitle
sx={{
alignItems: 'center',
display: 'flex',
gap: 1.35,
minHeight: 74,
pb: 1.3,
pl: 2.4,
pr: 6,
pt: 2,
}}
>
<Box
component="img"
alt={`${visual.symbol} coin`}
src={visual.coinImage}
sx={{
filter: 'drop-shadow(0 0 18px var(--wallet-glow-soft))',
height: 38,
objectFit: 'contain',
width: 38,
}}
/>
<Box sx={{ minWidth: 0 }}>
<Typography
sx={{
fontSize: 19,
fontWeight: 800,
letterSpacing: 0,
lineHeight: 1.1,
}}
>
{t('core:wallet.receive_symbol', { symbol: visual.symbol })}
</Typography>
<Typography
sx={{
color: 'text.secondary',
fontSize: 12.5,
fontWeight: 600,
lineHeight: 1.3,
mt: 0.35,
}}
>
{visual.name} address
</Typography>
</Box>
<IconButton
aria-label={t('core:wallet.close_receive_qr_dialog')}
onClick={onClose}
size="small"
sx={{
color: 'text.secondary',
position: 'absolute',
right: 14,
top: 14,
'&:hover': {
bgcolor: 'rgba(116,158,180,0.08)',
color: 'text.primary',
},
}}
>
<Close fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent
sx={{
display: 'grid',
gap: 1.55,
justifyItems: 'center',
px: 2.4,
pb: 2.4,
pt: 0,
}}
>
<Box
sx={{
bgcolor: '#fff',
borderRadius: 1.25,
boxShadow:
'0 0 0 1px rgba(255,255,255,0.08), 0 18px 42px rgba(0,0,0,0.22)',
p: 1.1,
width: 'min(246px, 100%)',
}}
>
<QRCode
value={value}
size={224}
fgColor="#000000"
bgColor="#ffffff"
level="H"
style={{ display: 'block', height: '100%', width: '100%' }}
/>
</Box>
<Box
sx={{
alignItems: 'center',
bgcolor: (t: Theme) =>
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%',
}}
>
<Typography
sx={{
color: value ? 'text.primary' : 'text.secondary',
flex: 1,
fontSize: 13.5,
fontWeight: 700,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{value || t('core:wallet.no_address_available')}
</Typography>
<CustomWidthTooltip
placement="top"
title={t('core:action.copy_address', {
postProcess: 'capitalizeFirstChar',
})}
>
<span style={{ display: 'inline-flex' }}>
<IconButton
disabled={!value}
onClick={() => copyToClipboard(value)}
size="small"
sx={{
color: 'text.secondary',
ml: 'auto',
'&:hover': { color: 'var(--wallet-accent)' },
}}
>
<CopyAllTwoTone fontSize="small" />
</IconButton>
</span>
</CustomWidthTooltip>
</Box>
</DialogContent>
</Dialog>
);
}
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<number | null>(null);
const previousTargetRef = useRef<number | null>(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 (
<WalletCard
sx={{
...getWalletVars(visual),
backgroundColor: 'transparent',
backgroundImage: 'none',
borderColor: 'transparent',
borderRadius: 0,
boxShadow: 'none',
minHeight: { md: 250 },
overflow: 'visible',
position: 'relative',
width: '100%',
}}
>
<Box
sx={{
alignItems: 'center',
display: 'grid',
gap: { xs: 2.25, md: 2.5 },
gridTemplateColumns: {
xs: '1fr',
md: 'minmax(150px, 0.46fr) minmax(220px, 0.68fr) minmax(280px, 0.86fr)',
xl: 'minmax(250px, 0.55fr) minmax(285px, 0.72fr) minmax(380px, 0.95fr)',
},
minHeight: { md: 250 },
pb: { xs: 2.25, md: 2.45 },
pl: { xs: 2.25, md: 2.5, lg: 3.4 },
pr: { xs: 2.25, md: 4.1 },
pt: { xs: 2.25, md: 2.45 },
position: 'relative',
zIndex: 1,
}}
>
<Box
aria-hidden="true"
sx={{
alignItems: 'center',
display: 'flex',
isolation: 'isolate',
justifyContent: { xs: 'center', md: 'flex-start' },
minHeight: { xs: 188, md: 210 },
minWidth: 0,
overflow: 'visible',
position: 'relative',
'&::before': {
background:
'radial-gradient(ellipse at 50% 50%, rgba(0,0,0,0.48) 0%, rgba(0,0,0,0.25) 35%, transparent 72%)',
bottom: { xs: 16, md: 21 },
content: '""',
filter: 'blur(var(--wallet-floor-shadow-blur, 7px))',
height: { xs: 20, md: 24 },
left: { xs: '21%', md: '7%' },
opacity: 'calc(0.62 * var(--wallet-floor-shadow-intensity, 1))',
pointerEvents: 'none',
position: 'absolute',
transform:
'translate(var(--wallet-floor-shadow-x, 0px), var(--wallet-floor-shadow-y, 0px)) perspective(180px) rotateX(64deg) scale(var(--wallet-floor-shadow-spread, 1))',
width: { xs: '58%', md: '78%' },
zIndex: 0,
},
'&::after': {
background: `
radial-gradient(ellipse at 48% 54%, color-mix(in srgb, var(--wallet-accent) 58%, transparent) 0%, color-mix(in srgb, var(--wallet-accent) 28%, transparent) 17%, transparent 50%),
radial-gradient(ellipse at 50% 66%, rgba(255,255,255,0.18) 0%, color-mix(in srgb, var(--wallet-accent) 18%, transparent) 12%, transparent 36%),
linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--wallet-accent) 22%, transparent) 26%, color-mix(in srgb, var(--wallet-accent) 11%, transparent) 55%, transparent 86%)
`,
bottom: { xs: 13, md: 17 },
borderRadius: '50%',
content: '""',
filter: 'blur(var(--wallet-floor-reflection-blur, 8px))',
height: { xs: 28, md: 38 },
left: { xs: '12%', md: '-3%' },
opacity:
'calc(0.78 * var(--wallet-floor-reflection-intensity, 1))',
pointerEvents: 'none',
position: 'absolute',
transform:
'translate(var(--wallet-floor-reflection-x, 0px), var(--wallet-floor-reflection-y, 0px)) perspective(190px) rotateX(66deg) scale(var(--wallet-floor-reflection-spread, 1))',
width: { xs: '82%', md: '126%' },
zIndex: 0,
},
}}
>
<Box
sx={{
alignItems: 'center',
aspectRatio: '1 / 1',
display: 'flex',
justifyContent: 'center',
position: 'relative',
width: 'clamp(154px, 15.4vw, 236px)',
zIndex: 1,
}}
>
<Box
component="img"
alt=""
src={visual.coinImage}
sx={{
filter:
'drop-shadow(var(--wallet-coin-shadow-x-effective, 0px) var(--wallet-coin-shadow-y-effective, 28px) var(--wallet-coin-shadow-blur-effective, 24px) rgba(0,0,0,calc(0.52 * var(--wallet-coin-shadow-intensity, 1)))) drop-shadow(0 0 11px color-mix(in srgb, var(--wallet-accent) 76%, transparent)) drop-shadow(22px 0 30px color-mix(in srgb, var(--wallet-accent) 28%, transparent))',
maxHeight: 'clamp(150px, 16.2vw, 250px)',
maxWidth: 'clamp(150px, 16.2vw, 250px)',
objectFit: 'contain',
opacity: 0.96,
position: 'relative',
animation: 'walletCoinFloat 5200ms ease-in-out infinite',
width: '112%',
zIndex: 1,
'@keyframes walletCoinFloat': {
'0%, 100%': {
transform: 'translateY(0) rotate(-4deg)',
},
'50%': {
transform: 'translateY(-5px) rotate(-3.2deg)',
},
},
}}
/>
</Box>
</Box>
<Box
sx={{
alignContent: 'center',
display: 'grid',
gap: { xs: 1.85, md: 1.7 },
minHeight: { md: 170 },
minWidth: 0,
}}
>
<Box
sx={{
display: 'grid',
gap: 0.65,
minHeight: 58,
minWidth: 0,
}}
>
<Box sx={{ alignItems: 'center', display: 'flex', gap: 0.75 }}>
<Typography
sx={{
color: 'text.primary',
fontSize: { xs: 22, md: 24 },
fontWeight: 700,
lineHeight: 1.05,
}}
>
{visual.name} Wallet
</Typography>
<VerifiedRounded
sx={{
color: 'var(--wallet-accent)',
filter: 'drop-shadow(0 0 8px var(--wallet-glow))',
fontSize: 19,
}}
/>
</Box>
<Typography sx={{ color: 'text.secondary', fontWeight: 600 }}>
{visual.symbol}
</Typography>
</Box>
<Box
sx={{
containerType: 'inline-size',
minWidth: 0,
}}
>
<Typography
variant="body2"
sx={{ color: 'text.secondary', fontWeight: 500 }}
>
{t('core:wallet.available_balance')}
</Typography>
<Typography
component="div"
sx={{
alignItems: 'baseline',
display: 'flex',
fontSize: balanceFontSize,
fontWeight: 700,
gap: '0.32em',
lineHeight: 1,
maxWidth: '100%',
minHeight: {
xs: 'clamp(2.15rem, 12vw, 3.1rem)',
md: 'clamp(2.6rem, 4vw, 3.45rem)',
},
mt: 0.55,
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
{isBalanceLoading && !balanceError ? (
<Box sx={{ alignSelf: 'center', maxWidth: 260, width: '100%' }}>
<LinearProgress />
</Box>
) : balanceError ? (
<Typography color="error.main" sx={{ fontWeight: 700 }}>
{balanceError}
</Typography>
) : (
<>
<Typography
component="span"
sx={{
font: 'inherit',
fontWeight: 'inherit',
lineHeight: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'clip',
whiteSpace: 'nowrap',
}}
>
{balanceAmount}
</Typography>
<Typography
component="span"
sx={{
flexShrink: 0,
fontSize: '0.34em',
fontWeight: 700,
lineHeight: 1,
}}
>
{visual.symbol}
</Typography>
</>
)}
</Typography>
</Box>
</Box>
<Box
sx={{
alignContent: 'center',
display: 'grid',
gap: 1.35,
minHeight: { md: 190 },
}}
>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontSize: 13,
fontWeight: 600,
lineHeight: 1,
}}
>
{t('core:wallet.your_address')}
</Typography>
<Box
sx={{
alignItems: 'center',
background: (t) =>
t.palette.mode === 'dark'
? 'linear-gradient(90deg, color-mix(in srgb, var(--wallet-accent) 7%, rgba(0, 8, 16, 0.4)) 0%, rgba(0, 8, 16, 0.36) 38%, rgba(0, 8, 16, 0.44) 100%)'
: 'linear-gradient(90deg, color-mix(in srgb, var(--wallet-accent) 7%, rgba(255,255,255,0.96)) 0%, rgba(255,255,255,0.92) 48%, rgba(248,252,255,0.96) 100%)',
border:
'1px solid color-mix(in srgb, var(--wallet-accent) 18%, rgba(116,158,180,0.24))',
borderRadius: 1,
boxShadow:
'inset 1px 0 0 color-mix(in srgb, var(--wallet-accent) 18%, transparent), inset 0 1px 0 rgba(255,255,255,0.035)',
display: 'flex',
gap: 1,
minHeight: 54,
minWidth: 0,
px: 1.35,
py: 0.9,
}}
>
<Typography
variant="body2"
sx={{
color: address ? 'text.primary' : 'text.secondary',
fontSize: 14,
fontWeight: 600,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{addressLabel}
</Typography>
<CustomWidthTooltip placement="top" title={displayCopyAddressLabel}>
<IconButton
size="small"
onClick={() => copyToClipboard(address ?? '')}
sx={{
color: 'text.secondary',
ml: 'auto',
'&:hover': { color: 'var(--wallet-accent)' },
}}
>
<CopyAllTwoTone fontSize="small" />
</IconButton>
</CustomWidthTooltip>
</Box>
<Box
sx={{
display: 'grid',
gap: 1.4,
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr' },
}}
>
<WalletButtons
variant="contained"
startIcon={<NorthEast />}
aria-label={`Send ${visual.symbol}`}
onClick={onSend}
sx={{
background: (t) =>
t.palette.mode === 'dark'
? 'linear-gradient(180deg, color-mix(in srgb, var(--wallet-accent) 36%, rgba(20,39,53,0.96)) 0%, color-mix(in srgb, var(--wallet-accent) 24%, rgba(7,18,30,0.98)) 100%)'
: 'linear-gradient(180deg, color-mix(in srgb, var(--wallet-accent) 86%, #ffffff) 0%, color-mix(in srgb, var(--wallet-accent) 72%, #0b5f8d) 100%)',
border:
'1px solid color-mix(in srgb, var(--wallet-accent) 52%, transparent)',
boxShadow:
'0 12px 28px color-mix(in srgb, var(--wallet-accent) 14%, transparent), inset 1px 0 0 color-mix(in srgb, var(--wallet-accent) 26%, transparent), inset 0 1px 0 rgba(255,255,255,0.1)',
color: (t) =>
t.palette.mode === 'dark' ? 'text.primary' : '#ffffff',
fontSize: 15,
fontWeight: 700,
minHeight: 52,
'&:hover': {
background: (t) =>
t.palette.mode === 'dark'
? 'linear-gradient(180deg, color-mix(in srgb, var(--wallet-accent) 46%, rgba(24,45,60,0.98)) 0%, color-mix(in srgb, var(--wallet-accent) 30%, rgba(7,18,30,0.98)) 100%)'
: 'linear-gradient(180deg, color-mix(in srgb, var(--wallet-accent) 92%, #ffffff) 0%, color-mix(in srgb, var(--wallet-accent) 76%, #0b5f8d) 100%)',
borderColor: 'var(--wallet-accent)',
boxShadow:
'0 14px 32px color-mix(in srgb, var(--wallet-accent) 24%, transparent), inset 0 1px 0 rgba(255,255,255,0.12)',
},
}}
>
{displaySendLabel}
</WalletButtons>
<Button
aria-label={
receiveOpen ? displayHideReceiveLabel : displayReceiveLabel
}
disableFocusRipple
disableRipple
onClick={onToggleReceive}
startIcon={<ReceiveActionIcon open={receiveOpen} />}
variant="outlined"
sx={{
bgcolor: (t) =>
t.palette.mode === 'dark'
? 'rgba(0, 8, 16, 0.28)'
: 'rgba(255,255,255,0.76)',
borderColor:
'color-mix(in srgb, var(--wallet-accent) 16%, rgba(116,158,180,0.24))',
boxShadow:
'inset 1px 0 0 color-mix(in srgb, var(--wallet-accent) 12%, transparent)',
color: 'var(--wallet-accent)',
fontSize: 15,
fontWeight: 700,
minHeight: 52,
transition:
'background-color 180ms ease, border-color 180ms ease, box-shadow 180ms ease, color 180ms ease',
'& .MuiButton-startIcon': {
alignItems: 'center',
display: 'inline-flex',
height: 20,
justifyContent: 'center',
mr: 0.9,
width: 20,
},
'&:hover': {
bgcolor:
'color-mix(in srgb, var(--wallet-accent) 8%, transparent)',
borderColor: 'var(--wallet-accent)',
},
}}
>
<ReceiveActionLabel
hideReceiveLabel={displayHideReceiveLabel}
open={receiveOpen}
receiveLabel={displayReceiveLabel}
/>
</Button>
</Box>
</Box>
</Box>
</WalletCard>
);
}
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<HTMLDivElement | null>(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 (
<WalletCard
sx={{
...getWalletVars(visual),
...walletOuterSurfaceSx,
alignItems: 'center',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.025)',
display: 'grid',
filter: 'none',
justifyItems: 'center',
minHeight: { md: 342 },
overflow: 'hidden',
p: { xs: 2, md: 2.25 },
width: '100%',
}}
>
<Box sx={{ textAlign: 'center', width: '100%' }}>
<Typography sx={{ fontSize: 15, fontWeight: 700, mb: 1.25 }}>
Receive {visual.symbol}
</Typography>
<Box
component="button"
type="button"
aria-label={`Show ${visual.symbol} receive QR code`}
onClick={onQrClick}
sx={{
appearance: 'none',
background: 'transparent',
border: 0,
borderRadius: 1.5,
cursor: onQrClick ? 'pointer' : 'default',
display: 'inline-grid',
font: 'inherit',
p: 0.35,
position: 'relative',
'&:hover .qr-target-frame': {
borderColor: 'var(--wallet-accent)',
},
}}
>
<Box
className="qr-target-frame"
sx={{
...walletInnerSurfaceSx,
border: '1px solid rgba(116,158,180,0.16)',
borderRadius: 1.5,
p: 0.95,
position: 'relative',
transition: 'border-color 160ms ease',
}}
>
<Box
ref={qrRef}
sx={{
aspectRatio: '1 / 1',
bgcolor: '#fff',
borderRadius: 1,
p: 1.05,
width: { xs: 190, md: 212 },
}}
>
<QRCode
value={value}
size={204}
fgColor="#000000"
bgColor="#ffffff"
level="H"
style={{ height: '100%', width: '100%' }}
/>
</Box>
</Box>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 1.25 }}>
Scan to receive
</Typography>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mt: 1.5,
}}
>
<Button
onClick={handleDownload}
size="small"
startIcon={<FileDownloadOutlined />}
variant="outlined"
>
{downloadLabel}
</Button>
</Box>
</Box>
</WalletCard>
);
}
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<AddressBookEntry[]>([]);
const [search, setSearch] = useState('');
const [draggedEntryId, setDraggedEntryId] = useState<string | null>(null);
const [dragOverEntryId, setDragOverEntryId] = useState<string | null>(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<HTMLButtonElement>,
entry: AddressBookEntry
) => {
event.stopPropagation();
event.currentTarget.blur();
const updatedEntries = toggleAddressBookFavorite(entry.id, visual.coinType);
if (updatedEntries) {
reloadEntries();
onAddressBookChange?.();
}
};
const handleEditContact = (
event: MouseEvent<HTMLButtonElement>,
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<AddressBookEntry, 'id' | 'createdAt'>
) => {
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<HTMLDivElement>,
entry: AddressBookEntry
) => {
setDraggedEntryId(entry.id);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', entry.id);
};
const handleDragOver = (
event: DragEvent<HTMLDivElement>,
entry: AddressBookEntry
) => {
if (!draggedEntryId || draggedEntryId === entry.id) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setDragOverEntryId(entry.id);
};
const handleDrop = (
event: DragEvent<HTMLDivElement>,
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 (
<>
<WalletCard
sx={{
...getWalletVars(visual),
background: (t) =>
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%',
}}
>
<Box sx={{ px: { xs: 1.75, md: 2.1 }, py: { xs: 1.75, md: 2 } }}>
<Typography sx={{ fontSize: 16, fontWeight: 700, mb: 1.2 }}>
{t('core:wallet.address_book_for_symbol', {
symbol: visual.symbol,
})}
</Typography>
<TextField
fullWidth
placeholder={t('core:address_book_ui.search_placeholder')}
size="small"
value={search}
onChange={(event) => setSearch(event.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search fontSize="small" sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
}}
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' },
},
}}
/>
<Button
fullWidth
startIcon={<Add />}
variant="outlined"
onClick={onAddContact}
sx={{
bgcolor: (t) =>
t.palette.mode === 'dark'
? 'rgba(13, 48, 72, 0.32)'
: 'rgba(255,255,255,0.62)',
borderColor: (t) =>
t.palette.mode === 'dark'
? 'rgba(116,158,180,0.16)'
: 'rgba(11,143,211,0.22)',
color: 'text.secondary',
fontWeight: 600,
mb: 1.35,
minHeight: 32,
py: 0.4,
'&:hover': {
bgcolor: (t) =>
t.palette.mode === 'dark'
? 'rgba(18, 64, 94, 0.42)'
: 'rgba(17,24,39,0.04)',
borderColor:
'color-mix(in srgb, var(--wallet-accent, #18bdf2) 28%, transparent)',
color: 'text.primary',
},
}}
>
{t('core:address_book_ui.add_contact')}
</Button>
<Box
sx={{
display: 'grid',
gap: 0.45,
overflow: 'visible',
}}
>
{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 (
<Box
key={entry.id}
draggable
role="button"
tabIndex={0}
onClick={(event: MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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',
},
}}
>
<Avatar
sx={{
...getAddressBookAvatarSx(avatarColor),
fontSize: 12,
fontWeight: 600,
height: 32,
width: 32,
}}
>
{initials}
</Avatar>
<Box
sx={{
alignContent: 'center',
display: 'grid',
gap: entry.note ? 0.2 : 0,
minHeight: 32,
minWidth: 0,
}}
>
<NameText
name={
visual.coinType === Coin.QORT ? entry.name : undefined
}
fallback={entry.name || '-'}
sx={{
fontSize: 14,
fontWeight: 600,
lineHeight: 1.15,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
/>
{entry.note && (
<Typography
variant="caption"
sx={{
color: 'text.secondary',
display: 'block',
fontSize: 12.5,
lineHeight: 1.2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{entry.note}
</Typography>
)}
</Box>
<Box
className="contact-actions"
sx={{
height: 32,
justifySelf: 'end',
position: 'relative',
width: 104,
}}
>
<CustomWidthTooltip
placement="top"
title={t('core:action.copy_address', {
postProcess: 'capitalizeFirstChar',
})}
>
<IconButton
className="contact-action contact-action-copy"
disableFocusRipple
disableRipple
size="small"
onClick={(event: MouseEvent<HTMLButtonElement>) => {
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',
},
}}
>
<CopyAllTwoTone fontSize="small" />
</IconButton>
</CustomWidthTooltip>
<CustomWidthTooltip
placement="top"
title={
entry.favorite
? t('core:address_book_ui.remove_favorite')
: t('core:address_book_ui.favorite')
}
>
<IconButton
className="contact-action contact-action-star"
disableFocusRipple
disableRipple
size="small"
onClick={(event: MouseEvent<HTMLButtonElement>) =>
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 ? (
<Star sx={{ fontSize: 19 }} />
) : (
<StarBorder sx={{ fontSize: 19 }} />
)}
</IconButton>
</CustomWidthTooltip>
<CustomWidthTooltip
placement="top"
title={t('core:address_book_ui.edit_contact')}
>
<IconButton
className="contact-action contact-action-edit"
disableFocusRipple
disableRipple
size="small"
onClick={(event: MouseEvent<HTMLButtonElement>) =>
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',
},
}}
>
<Edit fontSize="small" />
</IconButton>
</CustomWidthTooltip>
</Box>
</Box>
);
})
) : (
<Box
sx={{
alignItems: 'center',
background:
'linear-gradient(180deg, rgba(6, 30, 48, 0.2) 0%, rgba(4, 18, 31, 0.2) 100%)',
border: '1px solid rgba(116,158,180,0.075)',
borderRadius: 1,
color: 'text.secondary',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minHeight: { xs: 270, md: 300 },
px: { xs: 2.4, md: 2.7 },
py: { xs: 3.8, md: 4.35 },
textAlign: 'center',
}}
>
<Box
sx={{
alignItems: 'center',
display: 'flex',
justifyContent: 'center',
mb: 2.1,
position: 'relative',
}}
>
<Box
sx={{
alignItems: 'center',
bgcolor: 'rgba(116,158,180,0.09)',
border: '1px solid rgba(116,158,180,0.18)',
borderRadius: 1.4,
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.035)',
color: 'rgba(148,177,204,0.74)',
display: 'grid',
height: 62,
justifyItems: 'center',
width: 62,
}}
>
<Box
component="img"
src={visual.coinIcon}
alt=""
sx={{
filter:
'drop-shadow(0 0 12px color-mix(in srgb, var(--wallet-accent, #18bdf2) 36%, transparent))',
height: 36,
opacity: 0.92,
width: 36,
}}
/>
</Box>
</Box>
<Typography
sx={{
color: 'text.primary',
fontSize: { xs: 16, md: 17 },
fontWeight: 700,
lineHeight: 1.2,
}}
>
{search.trim()
? 'No matching contacts found'
: `No ${visual.symbol} contacts yet`}
</Typography>
<Typography
variant="body2"
sx={{
color: 'text.secondary',
fontSize: { xs: 13, md: 13.5 },
lineHeight: 1.45,
maxWidth: 310,
mt: 1.15,
opacity: 0.86,
}}
>
{search.trim()
? 'Try a different name, address or note.'
: `Add contacts to your address book to send ${visual.symbol} faster and avoid mistakes.`}
</Typography>
{!search.trim() && (
<Button
startIcon={<Add />}
variant="contained"
onClick={onAddContact}
sx={{
bgcolor: 'rgba(6,126,208,0.94)',
backgroundImage:
'linear-gradient(180deg, rgba(18,158,238,0.96), rgba(4,111,198,0.96))',
border: '1px solid rgba(85,205,255,0.4)',
borderRadius: 1.35,
boxShadow:
'0 14px 32px rgba(3,139,236,0.2), inset 0 1px 0 rgba(255,255,255,0.16)',
color: 'rgba(255,255,255,0.96)',
fontSize: { xs: 13.5, md: 14 },
fontWeight: 700,
mt: 2.7,
minHeight: 44,
px: 2.25,
whiteSpace: 'nowrap',
'& .MuiButton-startIcon': {
mr: 0.8,
'& svg': { fontSize: 20 },
},
'&:hover': {
bgcolor: '#1399e8',
borderColor: 'rgba(107,216,255,0.55)',
boxShadow:
'0 16px 36px rgba(24,189,242,0.25), inset 0 1px 0 rgba(255,255,255,0.18)',
},
}}
>
Add your first contact
</Button>
)}
</Box>
)}
</Box>
{hasMoreContacts && (
<Button
variant="text"
onClick={onAddContact}
sx={{
color: 'primary.main',
fontSize: 12,
fontWeight: 400,
justifyContent: 'flex-start',
lineHeight: 1,
minHeight: 16,
mt: 0.25,
px: 0,
py: 0,
textTransform: 'none',
'&:hover': {
bgcolor: 'transparent',
color: '#37d0ff',
},
}}
>
View all contacts
</Button>
)}
</Box>
</WalletCard>
<AddressFormDialog
coinType={visual.coinType}
disableRestoreFocus
entry={editingEntry}
onClose={handleEditFormClose}
onDelete={handleDeleteClick}
onExited={handleEditFormExited}
onSave={handleEditSave}
open={editFormOpen}
saveError={editSaveError}
/>
<DeleteConfirmationDialog
entryName={deletingEntry?.name || ''}
onClose={handleDeleteCancel}
onConfirm={handleDeleteConfirm}
open={deleteConfirmOpen}
/>
</>
);
}
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 (
<Box
role="status"
aria-live="polite"
sx={{
'@keyframes walletLoaderPulse': {
'0%, 100%': { opacity: 0.34, transform: 'scaleX(0.96)' },
'50%': { opacity: 0.9, transform: 'scaleX(1)' },
},
'@keyframes walletLoaderSweep': {
'0%': { transform: 'translateX(-110%)' },
'100%': { transform: 'translateX(110%)' },
},
display: 'grid',
gap: 1.5,
justifyItems: 'center',
px: { xs: 1, sm: 2 },
py: { xs: 3, md: 3.5 },
}}
>
<Box
sx={{
display: 'grid',
gap: 0.75,
maxWidth: 560,
width: 'min(100%, 560px)',
}}
>
{[0, 1, 2].map((index) => (
<Box
key={index}
sx={{
animation: `walletLoaderPulse 1450ms ease-in-out ${index * 110}ms infinite`,
bgcolor: 'rgba(116,158,180,0.12)',
border: '1px solid rgba(116,158,180,0.08)',
borderRadius: 1,
height: 12,
overflow: 'hidden',
position: 'relative',
transformOrigin: 'center',
width: index === 1 ? '84%' : index === 2 ? '68%' : '100%',
'&::after': {
animation: 'walletLoaderSweep 1550ms ease-in-out infinite',
background:
'linear-gradient(90deg, transparent, rgba(24,189,242,0.22), transparent)',
content: '""',
inset: 0,
position: 'absolute',
},
}}
/>
))}
</Box>
<Typography
sx={{
color: 'text.secondary',
fontSize: 13,
fontWeight: 600,
letterSpacing: 0,
}}
>
{displayLabel}
</Typography>
</Box>
);
}
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<HTMLButtonElement> | null,
newPage: number
) => void;
onRowsPerPageChange: (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => 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<string | null>(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 (
<Typography sx={{ color: 'text.secondary', fontSize: 13 }}>
-
</Typography>
);
}
return (
<Box
sx={{
display: 'grid',
gap: 0.45,
minWidth: 0,
}}
>
{addressEntries.map((entry, index) => (
<Box
key={`${entry.address}-${index}`}
sx={{
alignItems: 'start',
display: 'grid',
gap: 0.45,
gridTemplateColumns: 'minmax(0, 1fr) auto',
minWidth: 0,
}}
>
<Typography
component="span"
title={entry.address}
sx={{
color: entry.addressInWallet ? 'text.primary' : 'info.main',
display: 'block',
fontSize: 13,
fontWeight: entry.addressInWallet ? 500 : 600,
lineHeight: 1.25,
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{entry.address}
</Typography>
<CustomWidthTooltip
placement="top"
title={
copiedAddress === entry.address ? 'Copied' : 'Copy address'
}
>
<IconButton
aria-label="copy address"
onClick={() => handleCopyAddress(entry.address ?? '')}
size="small"
sx={{
color: 'text.secondary',
mt: -0.45,
p: 0.25,
}}
>
<CopyAllTwoTone sx={{ fontSize: 15 }} />
</IconButton>
</CustomWidthTooltip>
</Box>
))}
</Box>
);
};
const renderAmount = (row: WalletExternalTransactionRow) => {
const numeric = Number(row.totalAmount);
if (!Number.isFinite(numeric)) return '-';
const isIncoming = numeric > 0;
return (
<Typography
sx={{
color: isIncoming ? 'success.main' : 'error.main',
fontSize: 13,
fontWeight: 700,
textAlign: 'right',
whiteSpace: 'nowrap',
}}
>
{isIncoming ? '+' : ''}
{formatExternalTransactionAmount(row.totalAmount)} {coin}
</Typography>
);
};
const renderFee = (row: WalletExternalTransactionRow) => {
const numericFee = Number(row.feeAmount);
if (!Number.isFinite(numericFee) || numericFee === 0) return '-';
return (
<Typography
sx={{
color: Number(row.totalAmount) <= 0 ? 'error.main' : 'text.secondary',
fontSize: 13,
fontWeight: 500,
textAlign: 'right',
whiteSpace: 'nowrap',
}}
>
-{formatExternalTransactionAmount(row.feeAmount)}
</Typography>
);
};
const renderHash = (hash: string) => (
<Box
sx={{
alignItems: 'center',
display: 'flex',
gap: 0.35,
minWidth: 0,
}}
>
<Typography
component="span"
title={hash}
sx={{
color: 'text.primary',
display: 'block',
fontSize: 13,
fontWeight: 600,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{hash ? cropString(hash) : '-'}
</Typography>
{hash && (
<CustomWidthTooltip
placement="top"
title={copyHashLabel || labels.copyHash(hash)}
>
<IconButton
aria-label="copy"
size="small"
onClick={() => onCopyHash(hash)}
sx={{ color: 'text.secondary', p: 0.25 }}
>
<CopyAllTwoTone sx={{ fontSize: 16 }} />
</IconButton>
</CustomWidthTooltip>
)}
</Box>
);
const renderMobileField = (label: string, content: ReactNode) => (
<Box sx={{ display: 'grid', gap: 0.45, minWidth: 0 }}>
<Typography
sx={{
color: 'text.secondary',
fontSize: 10.5,
fontWeight: 700,
letterSpacing: 0,
lineHeight: 1,
textTransform: 'uppercase',
}}
>
{label}
</Typography>
<Box sx={{ minWidth: 0 }}>{content}</Box>
</Box>
);
if (!rows.length) {
return (
<Box
sx={{
bgcolor: 'transparent',
borderBottom: (t) => `1px solid ${t.palette.divider}`,
borderTop: (t) => `1px solid ${t.palette.divider}`,
color: 'text.secondary',
px: 2,
py: 4,
textAlign: 'center',
}}
>
<Typography sx={{ fontWeight: 600 }}>
{labels.noTransactions}
</Typography>
</Box>
);
}
return (
<>
<Box
sx={{
display: { xs: 'grid', sm: 'none' },
gap: 0.85,
minWidth: 0,
p: 1,
}}
>
{pagedRows.map((row, index) => {
const hash = row.txHash ?? '';
return (
<Box
key={hash || index}
sx={{
bgcolor: 'rgba(4, 22, 38, 0.22)',
border: (t) =>
`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,
}}
>
<Box
sx={{
alignItems: 'start',
display: 'grid',
gap: 1,
gridTemplateColumns: 'minmax(0, 1fr) auto',
minWidth: 0,
}}
>
{renderMobileField(labels.transactionHash, renderHash(hash))}
<Box sx={{ textAlign: 'right' }}>{renderAmount(row)}</Box>
</Box>
<Box
sx={{
display: 'grid',
gap: 1,
gridTemplateColumns: 'minmax(0, 1fr)',
minWidth: 0,
}}
>
{renderMobileField(labels.sender, renderEndpoint(row.inputs))}
{renderMobileField(
labels.receiver,
renderEndpoint(row.outputs)
)}
</Box>
{showMemo && (
<Box sx={{ minWidth: 0 }}>
{renderMobileField(
labels.memo ?? 'Memo',
<Typography
title={row.memo}
sx={{
color: row.memo ? 'text.primary' : 'text.secondary',
fontSize: 13,
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{row.memo || '-'}
</Typography>
)}
</Box>
)}
<Box
sx={{
alignItems: 'end',
display: 'grid',
gap: 1,
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
minWidth: 0,
}}
>
{renderMobileField(labels.fee, renderFee(row))}
{renderMobileField(
labels.time,
<CustomWidthTooltip
placement="top"
title={
row.timestamp
? new Date(row.timestamp).toLocaleString()
: labels.waitingConfirmation
}
>
<Typography
sx={{
color: 'text.secondary',
fontSize: 13,
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{row.timestamp ? epochToAgo(row.timestamp) : '-'}
</Typography>
</CustomWidthTooltip>
)}
</Box>
</Box>
);
})}
</Box>
<Box
sx={{
display: { xs: 'none', sm: 'block' },
maxWidth: '100%',
overflowX: 'auto',
overflowY: 'hidden',
overscrollBehaviorX: 'contain',
pb: 0.75,
touchAction: 'pan-x pan-y',
WebkitOverflowScrolling: 'touch',
'&::-webkit-scrollbar': {
height: 8,
},
'&::-webkit-scrollbar-thumb': {
bgcolor: 'rgba(116,158,180,0.28)',
borderRadius: 999,
},
'&::-webkit-scrollbar-track': {
bgcolor: 'rgba(116,158,180,0.08)',
},
}}
>
<Box sx={{ minWidth }}>
<Box
aria-hidden
sx={{
alignItems: 'center',
borderBottom: (t) => `1px solid ${t.palette.divider}`,
display: 'grid',
gap: 1,
gridTemplateColumns: gridColumns,
px: 1.25,
py: 1,
}}
>
<Typography sx={externalTransactionHeaderSx}>
{labels.sender}
</Typography>
<Typography sx={externalTransactionHeaderSx}>
{labels.receiver}
</Typography>
<Typography sx={externalTransactionHeaderSx}>
{labels.transactionHash}
</Typography>
{showMemo && (
<Typography sx={externalTransactionHeaderSx}>
{labels.memo}
</Typography>
)}
<Typography
sx={{ ...externalTransactionHeaderSx, textAlign: 'right' }}
>
{labels.totalAmount}
</Typography>
<Typography
sx={{ ...externalTransactionHeaderSx, textAlign: 'right' }}
>
{labels.fee}
</Typography>
<Typography sx={externalTransactionHeaderSx}>
{labels.time}
</Typography>
</Box>
<Box sx={{ display: 'grid' }}>
{pagedRows.map((row, index) => {
const hash = row.txHash ?? '';
return (
<Box
key={hash || index}
sx={{
...walletTableRowHoverSx,
alignItems: 'center',
bgcolor: 'transparent',
borderBottom: (t) =>
`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 && (
<Typography
title={row.memo}
sx={{
color: row.memo ? 'text.primary' : 'text.secondary',
fontSize: 13,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{row.memo || '-'}
</Typography>
)}
{renderAmount(row)}
{renderFee(row)}
<CustomWidthTooltip
placement="top"
title={
row.timestamp
? new Date(row.timestamp).toLocaleString()
: labels.waitingConfirmation
}
>
<Typography
sx={{
color: 'text.secondary',
fontSize: 13,
fontWeight: 500,
whiteSpace: 'nowrap',
}}
>
{row.timestamp ? epochToAgo(row.timestamp) : '-'}
</Typography>
</CustomWidthTooltip>
</Box>
);
})}
</Box>
</Box>
</Box>
<TablePagination
component="div"
labelRowsPerPage={labels.rowsPerPage}
rowsPerPageOptions={[5, 10, 25]}
count={rows.length}
rowsPerPage={rowsPerPage}
page={page}
slotProps={{
select: {
inputProps: {
'aria-label': labels.rowsPerPage,
},
MenuProps: {
disableScrollLock: true,
},
native: true,
},
}}
onPageChange={onPageChange}
onRowsPerPageChange={onRowsPerPageChange}
ActionsComponent={ActionsComponent}
sx={{
color: 'text.secondary',
mt: 0.5,
'& .MuiTablePagination-toolbar': {
flexWrap: { xs: 'wrap', sm: 'nowrap' },
minHeight: 44,
px: { xs: 1, sm: 0 },
rowGap: 0.5,
},
'& .MuiTablePagination-spacer': {
display: { xs: 'none', sm: 'block' },
},
'& .MuiTablePagination-selectLabel': {
display: { xs: 'none', sm: 'block' },
},
'& .MuiTablePagination-input': {
display: { xs: 'none', sm: 'inline-flex' },
},
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows':
{
color: 'text.secondary',
fontSize: 13,
},
}}
/>
</>
);
}
export function WalletTransactionsCard({
actions,
children,
isRefreshing,
onRefresh,
title,
}: WalletTransactionsCardProps) {
const { t } = useTranslation(['core']);
const displayTitle = title ?? t('core:wallet.transactions');
return (
<WalletCard
sx={{
background: (t) =>
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,
},
}}
>
<Box
sx={{
minWidth: 0,
px: { xs: 1.5, md: 2.25, lg: 2.75 },
pt: { xs: 1.5, md: 2.05 },
pb: 1.15,
}}
>
<Box
sx={{
alignItems: { xs: 'flex-start', sm: 'center' },
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
gap: 0.75,
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography
variant="subtitle1"
sx={{ fontWeight: 600, lineHeight: 1.1 }}
>
{displayTitle}
</Typography>
{(actions || onRefresh) && (
<Box
sx={{
alignItems: 'center',
display: 'flex',
gap: 0.75,
justifyContent: { xs: 'space-between', sm: 'flex-end' },
minWidth: 0,
width: { xs: '100%', sm: 'auto' },
}}
>
{actions}
{onRefresh && (
<Button
onClick={onRefresh}
loading={isRefreshing}
loadingPosition="start"
size="small"
startIcon={<Refresh sx={{ fontSize: 18 }} />}
variant="text"
sx={{
borderRadius: 1,
fontSize: 13,
fontWeight: 700,
minHeight: 34,
px: 1,
whiteSpace: 'nowrap',
'& .MuiButton-startIcon': {
mr: 0.65,
},
}}
>
{t('core:action.refresh', {
postProcess: 'capitalizeFirstChar',
})}
</Button>
)}
</Box>
)}
</Box>
</Box>
<Box
sx={{
background: (t) =>
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}
</Box>
</WalletCard>
);
}
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 (
<WalletCard
sx={{
background: (t) =>
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%',
}}
>
<Box sx={{ display: 'grid', gap: 1.35, px: { xs: 2, md: 2.25 }, py: 2 }}>
<Box
sx={{
alignItems: 'flex-start',
display: 'flex',
gap: 1,
justifyContent: 'space-between',
}}
>
<Box
sx={{
alignItems: 'center',
display: 'flex',
gap: 0.9,
minWidth: 0,
}}
>
<Box
sx={{
alignItems: 'center',
bgcolor: isError
? 'rgba(246, 167, 11, 0.08)'
: (t) =>
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,
}}
>
<LockOutlined fontSize="small" />
</Box>
<Box sx={{ minWidth: 0 }}>
<Box sx={{ alignItems: 'center', display: 'flex', gap: 0.4 }}>
<Typography
sx={{
color: 'text.secondary',
fontWeight: 600,
lineHeight: 1.15,
}}
>
{t('core:wallet.encrypted_sync')}
</Typography>
<CustomWidthTooltip placement="top" title={syncDescription}>
<InfoOutlined
sx={{
color: 'text.secondary',
cursor: 'help',
fontSize: 15,
opacity: 0.62,
}}
/>
</CustomWidthTooltip>
</Box>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
display: 'block',
mt: 0.45,
opacity: 0.78,
}}
>
{t('core:wallet.local_address_book')}
</Typography>
</Box>
</Box>
<Box
title={statusTooltip}
sx={{
alignItems: 'center',
bgcolor: isError
? 'rgba(246, 167, 11, 0.08)'
: 'rgba(34,227,138,0.055)',
border: isError
? '1px solid rgba(246, 196, 78, 0.18)'
: '1px solid rgba(34,227,138,0.14)',
borderRadius: 999,
color: isError
? 'rgba(246, 196, 78, 0.94)'
: 'rgba(144, 214, 176, 0.88)',
display: 'inline-flex',
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
gap: 0.5,
px: 1,
py: 0.45,
}}
>
{isError ? (
<ErrorOutline sx={{ fontSize: 15 }} />
) : (
<CheckCircleOutline sx={{ fontSize: 15 }} />
)}
{statusLabel}
</Box>
</Box>
<Button
fullWidth
loading={isSyncing}
loadingPosition="start"
onClick={onSync}
startIcon={<Sync />}
variant="outlined"
sx={{
bgcolor: (t) =>
t.palette.mode === 'dark'
? 'rgba(13, 48, 72, 0.32)'
: 'rgba(255,255,255,0.62)',
borderColor: (t) =>
t.palette.mode === 'dark'
? 'rgba(116,158,180,0.16)'
: 'rgba(11,143,211,0.22)',
color: 'text.secondary',
fontWeight: 600,
minHeight: 42,
'&:hover': {
bgcolor: (t) =>
t.palette.mode === 'dark'
? 'rgba(18, 64, 94, 0.42)'
: 'rgba(239,248,252,0.95)',
borderColor: (t) =>
t.palette.mode === 'dark'
? 'rgba(116,158,180,0.28)'
: 'rgba(11,143,211,0.32)',
color: 'text.primary',
},
}}
>
{t('core:wallet.sync_now')}
</Button>
</Box>
</WalletCard>
);
}
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<HTMLDivElement | null>(null);
const mainColumnRef = useRef<HTMLDivElement | null>(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 (
<Box
ref={workspaceRef}
sx={{
...getWalletVars(visual),
...WALLET_GLOW_CSS_VARS,
alignItems: 'start',
display: 'grid',
gap: { xs: 1.8, lg: 2, xl: 3 },
// Two columns only when the side panels measurably fit beside the main
// content (see showSidePanels); otherwise the main content spans the
// full width and the panels are hidden rather than overlapping it.
gridTemplateColumns: showSidePanels
? 'minmax(0, 1fr) minmax(404px, 424px)'
: '1fr',
isolation: 'isolate',
position: 'relative',
width: '100%',
}}
>
<Box
ref={mainColumnRef}
sx={{
display: 'grid',
// Constrain children to the column's track (instead of the implicit
// auto/max-content track) so the summary and transaction table fit or
// scroll within it rather than overflowing under the side panels.
gridTemplateColumns: 'minmax(0, 1fr)',
gap: { xs: 1.8, md: 2 },
minWidth: 0,
position: 'relative',
zIndex: 1,
}}
>
<WalletSummaryCard
address={address}
balance={balance}
balanceDecimals={balanceDecimals}
balanceError={balanceError}
coin={coin}
isBalanceLoading={isBalanceLoading}
noAddressLabel={noAddressLabel}
onSend={onSend}
onToggleReceive={onToggleReceive}
receiveOpen={receiveOpen}
/>
{children}
<Box sx={{ mt: { xs: 0, md: -0.85 } }}>{transactions}</Box>
</Box>
<Box
sx={{
display: 'grid',
gap: 0,
minWidth: 0,
position: 'relative',
zIndex: 1,
}}
>
<Box
sx={{
height: {
xs: 'auto',
lg: receiveOpen ? RECEIVE_QR_SLOT_HEIGHT : 0,
},
mb: { xs: receiveOpen ? 1.6 : 0, lg: 0 },
overflow: 'visible',
pointerEvents: receiveOpen ? 'auto' : 'none',
transition: {
xs: 'margin-bottom 620ms cubic-bezier(0.4, 0, 0.2, 1)',
lg: 'height 620ms cubic-bezier(0.4, 0, 0.2, 1)',
},
}}
>
<Collapse
in={receiveOpen}
timeout={620}
unmountOnExit
easing={{
enter: 'cubic-bezier(0.16, 1, 0.3, 1)',
exit: 'cubic-bezier(0.4, 0, 0.2, 1)',
}}
sx={{
'& .MuiCollapse-wrapper': {
overflow: 'hidden',
},
'& .MuiCollapse-wrapperInner': {
overflow: 'hidden',
},
}}
>
<ReceiveQrMotionContent
address={address}
coin={coin}
onQrClick={() => setReceiveQrDialogOpen(true)}
open={receiveOpen}
/>
</Collapse>
</Box>
<Box
sx={{
// Shown only when the panels measurably fit beside the main content
// (see showSidePanels); otherwise they are hidden so they never
// overlap the wallet and transaction content.
display: showSidePanels ? 'block' : 'none',
position: 'relative',
zIndex: 1,
}}
>
<WalletAddressBookPanel
coin={coin}
onAddContact={onAddContact}
onAddressBookChange={onAddressBookChange}
onSelectAddress={onSelectAddress}
refreshKey={addressBookRefreshKey}
/>
{rightColumnAfter ? (
<Box sx={{ mt: { xs: 1.6, md: 3 } }}>{rightColumnAfter}</Box>
) : null}
</Box>
</Box>
<ReceiveQrDialog
address={address}
coin={coin}
onClose={() => setReceiveQrDialogOpen(false)}
open={receiveQrDialogOpen}
/>
</Box>
);
}