Repository Public
1import { useQuery } from '@tanstack/react-query';2import { useContext, useState } from 'react';3import { get } from '../api';4import type { Nav } from '../App';5import { PriceChart, type Pt } from '../chart';6import { useNetworks, usePortfolio, useSettings } from '../hooks';7import { I18n, useT } from '../i18n';8import { Addr, Card, Change, Coin, CopyBtn, fiat, H, Icon, Money, NetDot, Notice, numFmt, Skeleton, StatePill, trimAmt, when } from '../ui';9import { issuerOf } from '../issuer';1011const RANGES = [['1', '1J', '1D'], ['7', '1S', '1W'], ['30', '1M', '1M'], ['365', '1A', '1Y']] as const;12const compact = (n: number | null, cur: string, locale: string) => (n === null ? '—' : new Intl.NumberFormat(locale === 'fr' ? 'fr-CA' : 'en-CA', { style: 'currency', currency: cur, notation: 'compact', maximumFractionDigits: 2 }).format(n));1314export function Asset({ network, asset, nav }: { network: string; asset: string; nav: Nav }) {15 const t = useT(); const { locale } = useContext(I18n); const { settings } = useSettings();16 const pf = usePortfolio(), nets = useNetworks();17 const [days, setDays] = useState<'1' | '7' | '30' | '365'>('7'), [hover, setHover] = useState<Pt | null>(null);18 const cur = settings.currency, ck = cur === 'USD' ? 'usd' : 'cad';19 const b = pf.data?.balances.find((x) => x.network === network && x.asset === asset);20 const mk = useQuery({ queryKey: ['market', b?.symbol, days, ck], enabled: !!b, staleTime: 120_000,21 queryFn: () => get<{ points: Pt[]; market: any }>(`/market/${encodeURIComponent(b!.symbol)}?days=${days}¤cy=${ck}`) });22 const act = useQuery({ queryKey: ['activity'], queryFn: () => get<{ items: any[] }>('/activity?limit=200'), staleTime: 15_000 });23 if (!b || !pf.data) return <div className="space-y-5"><Skeleton h="h-72" /><Skeleton h="h-40" /></div>;24 const n = nets.data?.find((x) => x.id === network);25 const m = pf.data.prices?.[b.symbol] ?? mk.data?.market ?? null;26 const price: number | null = m ? m[ck] : null;27 const pts = mk.data?.points ?? [];28 const shown = hover?.p ?? (pts.length ? pts[pts.length - 1]!.p : price);29 const base = pts.length ? pts[0]!.p : null;30 const delta = shown !== null && base ? ((shown - base) / base) * 100 : null;31 const money = (v: number) => fiat(v, cur, locale);32 const priceFmt = (v: number) => new Intl.NumberFormat(locale === 'fr' ? 'fr-CA' : 'en-CA', { style: 'currency', currency: cur, maximumFractionDigits: v < 1 ? 6 : 2 }).format(v);33 const timeFmt = (ts: number) => new Date(ts).toLocaleString(locale === 'fr' ? 'fr-CA' : 'en-CA', days === '1' ? { hour: '2-digit', minute: '2-digit' } : days === '365' ? { month: 'short', year: 'numeric' } : { day: 'numeric', month: 'short' });34 const hidden = settings.hideBalances;35 const value = b.display !== null && price !== null ? Number(b.display) * price : null;36 const others = pf.data.balances.filter((x) => x.symbol === b.symbol && !(x.network === network && x.asset === asset));37 const mine = (act.data?.items ?? []).filter((i) => i.network === network && i.asset === asset).slice(0, 6);38 const iss = asset === 'native' ? null : issuerOf(t, b.symbol, b.name);39 const sgn = (v: number) => `${v > -0.005 ? '+' : '−'}${numFmt(Math.abs(v), locale, 2, 2)} %`;40 const stat = (k: string, v: string) => <div className="rounded-2xl bg-elev/70 p-3.5"><div className="text-[12px] text-mute">{k}</div><div className="mt-0.5 text-[15px] font-semibold">{v}</div></div>;41 return (42 <div className="space-y-5">43 <div className="flex items-center gap-3 pt-1">44 <button onClick={() => nav({ tab: 'home' })} aria-label={t('back')} className="-ml-2 grid h-11 w-11 shrink-0 place-items-center rounded-full text-ink"><Icon n="chev" className="h-6 w-6 rotate-180" /></button>45 <Coin sym={b.symbol} net={b.network} size={40} />46 <div className="min-w-0"><h1 className="truncate text-[20px] font-semibold leading-tight tracking-tight">{b.name}</h1><div className="text-[13px] text-mute">{b.symbol} · {b.networkName}</div></div>47 </div>4849 {iss && <div data-testid="asset-issuer"><Notice tone="warn">{t('qaPegNote', { name: b.name, sym: b.symbol, issuer: iss.text })}</Notice></div>}50 <Card className="!pb-4">51 <div className="text-[13px] text-mute">{hover ? timeFmt(hover.t) : t('price')}</div>52 <div className="mt-1 font-[family-name:var(--font-display)] text-[40px] font-semibold leading-none tracking-[-0.03em]">{shown !== null && shown !== undefined ? priceFmt(shown) : '—'}</div>53 <div className="mt-2 h-5">{delta !== null && <Change pct={delta} suffix={t(`r_${days}` as never)} />}</div>54 <div className="mt-3">{mk.isLoading ? <div className="h-[210px] animate-pulse rounded-2xl bg-elev/70" /> : <PriceChart points={pts} format={priceFmt} formatTime={timeFmt} onHover={setHover} />}</div>55 <div className="mt-4 flex rounded-2xl bg-elev p-1" role="radiogroup" aria-label={t('range')}>{RANGES.map(([d, fr, en]) =>56 <button key={d} role="radio" aria-checked={days === d} onClick={() => { setDays(d); setHover(null); }} className={`min-h-11 flex-1 rounded-xl text-[13.5px] ${days === d ? 'bg-surface font-semibold shadow-sm' : 'text-mute'}`}>{locale === 'fr' ? fr : en}</button>)}</div>57 {!pf.data.prices && <p className="mt-3 text-[12.5px] text-mute">{t('noPrices')}</p>}58 </Card>5960 <Card>61 <div className="flex items-end justify-between gap-3">62 <div><div className="text-[13px] text-mute">{t('yourBalance')}</div>63 <div className="mt-1 text-[26px] font-semibold leading-tight tracking-tight"><Money hidden={hidden}>{b.display === null ? '—' : trimAmt(b.display, 8)} <span className="text-mute">{b.symbol}</span></Money></div>64 {value !== null && <div className="text-[14px] text-mute"><Money hidden={hidden}>{money(value)}</Money></div>}</div>65 </div>66 <div className="mt-4 grid grid-cols-2 gap-3">67 <button onClick={() => nav({ tab: 'receive', network })} className="inline-flex min-h-[50px] items-center justify-center gap-2 rounded-2xl bg-primary px-4 text-[15.5px] font-semibold text-onprimary"><Icon n="down" className="h-[18px] w-[18px]" />{t('receive')}</button>68 <button onClick={() => nav({ tab: 'send', network, asset })} disabled={pf.data.halted} className="inline-flex min-h-[50px] items-center justify-center gap-2 rounded-2xl bg-elev px-4 text-[15.5px] font-semibold disabled:opacity-40"><Icon n="up" className="h-[18px] w-[18px]" />{t('send')}</button>69 </div>70 </Card>7172 {others.length > 0 && <Card className="!px-0 !pb-1"><div className="px-5"><H>{t('otherNetworks', { sym: b.symbol })}</H></div>73 <ul>{others.map((o, k) => <li key={o.network + o.asset}><button onClick={() => nav({ tab: 'asset', network: o.network, asset: o.asset })} className={`flex w-full items-center gap-3 px-5 py-3 text-left ${k ? 'border-t border-line' : ''}`}>74 <NetDot net={o.network} /><span className="flex-1 text-[15px]">{o.networkName}</span>75 <span className={`text-[15px] font-semibold ${o.amount === '0' ? 'text-mute' : ''}`}><Money hidden={hidden}>{o.display === null ? '—' : trimAmt(o.display)}</Money></span><Icon n="chev" className="h-4 w-4 text-mute" /></button></li>)}</ul></Card>}7677 {m && <Card><H>{t('marketData')}</H>78 <div className="grid grid-cols-2 gap-3">79 {stat(t('change24h'), m.change24h === null ? '—' : sgn(m.change24h))}80 {stat(t('change7d'), m.change7d === null ? '—' : sgn(m.change7d))}81 {stat(t('marketCap'), compact(m.marketCap, 'USD', locale))}82 {stat(t('volume24h'), compact(m.volume24h, 'USD', locale))}83 {stat(t('range24h'), m.low24h === null || m.high24h === null ? '—' : `${fiat(m.low24h, 'USD', locale)} – ${fiat(m.high24h, 'USD', locale)}`)}84 {stat(t('rank'), m.rank ? `#${m.rank}` : '—')}85 </div>86 <p className="mt-3 text-[12px] text-mute">{t('marketSource')}</p></Card>}8788 <Card><H>{t('assetDetails')}</H>89 <dl className="space-y-3 text-[14px]">90 <div className="flex justify-between gap-4"><dt className="text-mute">{t('network')}</dt><dd className="flex items-center gap-2 font-medium"><NetDot net={b.network} />{b.networkName}</dd></div>91 <div className="flex justify-between gap-4"><dt className="text-mute">{t('decimals')}</dt><dd className="font-medium">{b.decimals}</dd></div>92 <div><dt className="text-mute">{asset === 'native' ? t('nativeCoin') : t('contract')}</dt>93 {asset !== 'native' && <dd className="mt-1"><Addr value={asset} /><div className="mt-2 flex gap-2"><CopyBtn text={asset} />{n && <a href={n.explorerAddr + asset} target="_blank" rel="noreferrer noopener" className="inline-flex min-h-[50px] items-center gap-2 rounded-2xl border border-line px-4 text-[15px]"><Icon n="ext" className="h-4 w-4" />{t('explorer')}</a>}</div></dd>}</div>94 </dl>95 {asset !== 'native' && <p className="mt-3 text-[12px] text-mute">{t('verifiedToken')}</p>}</Card>9697 <Card className="!px-0 !pb-1"><div className="px-5"><H right={<button className="-my-2 min-h-11 min-w-11 text-[13.5px] font-medium text-blue" onClick={() => nav({ tab: 'activity' })}>{t('all')}</button>}>{t('activity')}</H></div>98 {mine.length === 0 ? <p className="px-5 pb-4 text-[14px] text-mute">{t('noActivity')}</p> :99 <ul>{mine.map((i, k) => <li key={i.id} className={`flex items-center gap-3 px-5 py-3 ${k ? 'border-t border-line' : ''}`}>100 <span className={`grid h-9 w-9 place-items-center rounded-full ${i.kind === 'receive' ? 'bg-emerald/10 text-emerald' : 'bg-blue/10 text-blue'}`}><Icon n={i.kind === 'receive' ? 'down' : 'up'} className="h-4 w-4" /></span>101 <span className="min-w-0 flex-1"><span className="block text-[14.5px] font-medium">{i.kind === 'receive' ? t('received') : t('sent')}</span><span className="block text-[12px] text-mute">{when(i.at, locale)}</span></span>102 <span className="text-right"><span className="block text-[14.5px] font-semibold"><Money hidden={hidden}>{i.kind === 'receive' ? '+' : '−'}{trimAmt(i.amountDisplay)}</Money></span><StatePill state={i.state} /></span></li>)}</ul>}</Card>103 </div>104 );105}106