SPB Git

Repository Public

Asset.tsx 10.0 KB 106 lines tsx

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}&currency=${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
Files main
Menu

Theme