โ† Back
โ˜†
import { useMemo, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useUserAnalytics } from '../hooks/useUserAnalytics';
import type { PopularLeader, UserRow } from '../hooks/useUserAnalytics';
import { useIsAdmin } from '../hooks/useIsAdmin';
import { useT } from '../i18n/LanguageContext';
import { whaleCodename, whaleCodenamesUnique } from '../utils/whales';

function fmtUsd(val: number): string {
  const a = Math.abs(val);
  const sign = val < 0 ? '-' : '';
  if (a >= 1_000_000) return `${sign}$${(a / 1_000_000).toFixed(1)}M`;
  if (a >= 1_000) return `${sign}$${(a / 1_000).toFixed(1)}K`;
  return `${sign}$${a.toFixed(2)}`;
}

function fmtPct(v: number | null): string {
  return v == null ? 'โ€”' : `${Math.round(v * 100)}%`;
}

function short(addr: string | null): string {
  if (!addr) return 'โ€”';
  return `${addr.slice(0, 6)}โ€ฆ${addr.slice(-4)}`;
}

// ISO-3166 alpha-2 โ†’ flag emoji (regional indicators); no asset needed.
// Returns '' for unknown so the cell shows a muted dash instead of a white flag.
function flag(cc: string | null): string {
  const up = (cc || '').toUpperCase();
  if (!/^[A-Z]{2}$/.test(up)) return '';
  return String.fromCodePoint(...[...up].map(c => 0x1f1e6 + c.charCodeAt(0) - 65));
}

function fmtDate(iso: string | null): string {
  if (!iso) return 'โ€”';
  try {
    return new Date(iso.includes('Z') || iso.includes('T') ? iso : `${iso}Z`)
      .toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
  } catch {
    return iso;
  }
}

type Dir = 'asc' | 'desc';
interface SortState<K extends string> { key: K; dir: Dir }

// Stable sort by an accessor; null/empty always sink to the bottom regardless of direction.
function sortRows<T>(rows: T[], accessor: (r: T) => unknown, dir: Dir): T[] {
  const m = dir === 'asc' ? 1 : -1;
  return [...rows].sort((x, y) => {
    const a = accessor(x); const b = accessor(y);
    const an = a == null || a === ''; const bn = b == null || b === '';
    if (an && bn) return 0;
    if (an) return 1;
    if (bn) return -1;
    if (typeof a === 'number' && typeof b === 'number') return (a - b) * m;
    return String(a).localeCompare(String(b)) * m;
  });
}

// Clickable header cell: click to sort, click again to flip. New column defaults to
// desc for numbers, asc for text. Shows โ–ฒ/โ–ผ on the active column.
function SortTh<K extends string>({ label, col, numeric, center, sort, setSort }: {
  label: string; col: K; numeric?: boolean; center?: boolean; sort: SortState<K>; setSort: (s: SortState<K>) => void;
}) {
  const active = sort.key === col;
  const onClick = () => setSort(
    active ? { key: col, dir: sort.dir === 'asc' ? 'desc' : 'asc' }
           : { key: col, dir: numeric ? 'desc' : 'asc' },
  );
  const align: 'left' | 'right' | 'center' = center ? 'center' : numeric ? 'right' : 'left';
  return (
    <th onClick={onClick}
        style={{ cursor: 'pointer', userSelect: 'none', whiteSpace: 'nowrap', textAlign: align }}>
      {label}<span style={{ opacity: active ? 0.9 : 0.25 }}>{active ? (sort.dir === 'asc' ? ' โ–ฒ' : ' โ–ผ') : ' โ†•'}</span>
    </th>
  );
}

const LEADER_COLS = {
  name: (l: PopularLeader) => whaleCodename(l.address, 'en'),
  subscribers: (l: PopularLeader) => l.subscribers,
  winRate: (l: PopularLeader) => l.winRate,
  pnl: (l: PopularLeader) => l.pnl,
  volume: (l: PopularLeader) => l.volume,
  trades: (l: PopularLeader) => l.trades,
  category: (l: PopularLeader) => l.category,
} satisfies Record<string, (l: PopularLeader) => unknown>;
type LeaderCol = keyof typeof LEADER_COLS;
const LEADER_HEADERS: { col: LeaderCol; label: string; numeric?: boolean }[] = [
  { col: 'name', label: 'ua.colLeader' },
  { col: 'subscribers', label: 'ua.colSubs', numeric: true },
  { col: 'winRate', label: 'ua.colWin', numeric: true },
  { col: 'pnl', label: 'ua.colPnl', numeric: true },
  { col: 'volume', label: 'ua.colVolume', numeric: true },
  { col: 'trades', label: 'ua.colTrades', numeric: true },
  { col: 'category', label: 'ua.colCategory' },
];

// Equity = deposit + open-position value; totalPnl = realized + unrealized.
const equityOf = (u: UserRow) => (u.depositBalance || 0) + (u.positionsValue || 0);
const totalPnlOf = (u: UserRow) => u.realizedPnl + u.unrealizedPnl;
// Builder fees the user generated for us โ€” REAL, from CLOB /builder/trades grouped
// by on-chain `maker` (= the user's deposit wallet). Replaces the old `notional ร— 0.5%`
// estimate, which over-counted pre-activation trades (builderFee was $0 before taker fee).
const builderFeesOf = (u: UserRow) => u.builderFees;
const USER_COLS = {
  user: (u: UserRow) => u.email || u.address,
  country: (u: UserRow) => u.country,
  createdAt: (u: UserRow) => u.createdAt,
  lastLogin: (u: UserRow) => u.lastLogin,
  activeSubs: (u: UserRow) => u.activeSubs,
  openPositions: (u: UserRow) => u.openPositions,
  closedPositions: (u: UserRow) => u.closedPositions,
  depositBalance: (u: UserRow) => u.depositBalance ?? -1,
  positionsValue: (u: UserRow) => u.positionsValue,
  equity: equityOf,
  notional: (u: UserRow) => u.notional,
  builderFees: builderFeesOf,
  realizedPnl: (u: UserRow) => u.realizedPnl,
  unrealizedPnl: (u: UserRow) => u.unrealizedPnl,
  totalPnl: totalPnlOf,
} satisfies Record<string, (u: UserRow) => unknown>;
type UserCol = keyof typeof USER_COLS;
// label = i18n key (resolved with t() at render time).
const USER_HEADERS: { col: UserCol; label: string; numeric?: boolean; center?: boolean }[] = [
  { col: 'user', label: 'ua.colUser' },
  { col: 'country', label: 'ua.colCountry', center: true },
  { col: 'createdAt', label: 'ua.colJoined' },
  { col: 'lastLogin', label: 'ua.colActive' },
  { col: 'activeSubs', label: 'ua.colSubs', numeric: true },
  { col: 'openPositions', label: 'ua.colOpen', numeric: true },
  { col: 'closedPositions', label: 'ua.colClosed', numeric: true },
  { col: 'depositBalance', label: 'ua.colDeposit', numeric: true },
  { col: 'positionsValue', label: 'ua.colInPositions', numeric: true },
  { col: 'equity', label: 'ua.colEquity', numeric: true },
  { col: 'notional', label: 'ua.colNotional', numeric: true },
  { col: 'builderFees', label: 'ua.colBuilderFees', numeric: true },
  { col: 'realizedPnl', label: 'ua.colRealized', numeric: true },
  { col: 'unrealizedPnl', label: 'ua.colUnrealized', numeric: true },
  { col: 'totalPnl', label: 'ua.colTotalPnl', numeric: true },
];

export default function UserAnalyticsPage() {
  const isAdmin = useIsAdmin();
  const navigate = useNavigate();
  const { lang, t } = useT();
  const { totals, leaders, users, loading, refresh } = useUserAnalytics();

  const [leaderSort, setLeaderSort] = useState<SortState<LeaderCol>>({ key: 'subscribers', dir: 'desc' });
  const [userSort, setUserSort] = useState<SortState<UserCol>>({ key: 'equity', dir: 'desc' });
  const sortedLeaders = useMemo(
    () => sortRows(leaders, LEADER_COLS[leaderSort.key], leaderSort.dir),
    [leaders, leaderSort],
  );
  const leaderNames = useMemo(
    () => whaleCodenamesUnique(leaders.map((l) => l.address), lang),
    [leaders, lang],
  );
  const sortedUsers = useMemo(
    () => sortRows(users, USER_COLS[userSort.key], userSort.dir),
    [users, userSort],
  );

  // Block deep-links: only allowlisted emails may view internal user data.
  if (!isAdmin) return <Navigate to="/" replace />;

  return (
    <div className="portfolio-page">
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
        <h2 className="portfolio-title">{t('ua.title')}</h2>
        <button className="page-btn" onClick={refresh} disabled={loading} style={{ fontSize: 13 }}>
          {loading ? t('ua.loading') : t('ua.refresh')}
        </button>
      </div>

      {/* Cross-link to the other admin analytics page */}
      <div style={{ marginBottom: 12 }}>
        <Link to="/analytics/szhub2026" className="page-btn" style={{ fontSize: 13, textDecoration: 'none' }}>
          {t('ua.builderLink')}
        </Link>
      </div>

      {/* KPI Cards */}
      <div className="analytics-kpi-grid">
        <KpiCard label={t('ua.totalUsers')} value={String(totals.totalUsers)} accent />
        <KpiCard label={t('ua.onboarded')} value={String(totals.onboarded)} sub={t('ua.onboardedSub')} />
        <KpiCard label={t('ua.activeCopiers')} value={String(totals.activeCopiers)} />
        <KpiCard
          label={t('ua.subscriptions')}
          value={String(totals.activeSubs)}
          sub={t('ua.subsPaused', { n: totals.pausedSubs })}
        />
        <KpiCard
          label={t('ua.copyPositions')}
          value={String(totals.openPositions)}
          sub={t('ua.positionsClosed', { n: totals.closedPositions })}
        />
        <KpiCard label={t('ua.totalDeposits')} value={fmtUsd(totals.totalDeposits)} />
        <KpiCard label={t('ua.totalInPositions')} value={fmtUsd(totals.totalPositionsValue)} />
        <KpiCard
          label={t('ua.realizedPnl')}
          value={fmtUsd(totals.realizedPnl)}
          accent
        />
      </div>

      {/* Loading skeleton */}
      {loading && users.length === 0 && leaders.length === 0 && (
        <div className="portfolio-loading">
          {Array.from({ length: 3 }, (_, i) => (
            <div key={i} className="position-card">
              <div className="skeleton-line" style={{ width: '70%', height: 16 }} />
              <div className="skeleton-line" style={{ width: '50%', height: 14, marginTop: 8 }} />
            </div>
          ))}
        </div>
      )}

      {/* Most-copied leaders */}
      {leaders.length > 0 && (
        <>
          <h3 className="analytics-section-title">{t('ua.leadersTitle')}</h3>
          <div className="analytics-table-wrap">
            <table className="analytics-table">
              <thead>
                <tr>
                  {LEADER_HEADERS.map((h) => (
                    <SortTh key={h.col} label={t(h.label)} col={h.col} numeric={h.numeric}
                            sort={leaderSort} setSort={setLeaderSort} />
                  ))}
                </tr>
              </thead>
              <tbody>
                {sortedLeaders.map((l: PopularLeader) => (
                  <tr
                    key={l.address}
                    onClick={() => navigate('/whale', { state: { address: l.address } })}
                    style={{ cursor: 'pointer' }}
                  >
                    <td title={l.address}>{leaderNames.get(l.address.toLowerCase()) ?? whaleCodename(l.address, lang)}</td>
                    <td style={{ textAlign: 'right' }}>{l.subscribers}</td>
                    <td style={{ textAlign: 'right' }}>{fmtPct(l.winRate)}</td>
                    <td style={{ textAlign: 'right' }} className={l.pnl == null ? undefined : l.pnl >= 0 ? 'text-profit' : 'text-loss'}>
                      {l.pnl == null ? 'โ€”' : fmtUsd(l.pnl)}
                    </td>
                    <td style={{ textAlign: 'right' }}>{l.volume == null ? 'โ€”' : fmtUsd(l.volume)}</td>
                    <td style={{ textAlign: 'right' }}>{l.trades ?? 'โ€”'}</td>
                    <td>{l.category || 'โ€”'}</td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </>
      )}

      {/* Per-user breakdown */}
      {users.length > 0 && (
        <>
          <h3 className="analytics-section-title">{t('ua.usersTitle')}</h3>
          <div className="analytics-table-wrap">
            <table className="analytics-table">
              <thead>
                <tr>
                  {USER_HEADERS.map((h) => (
                    <SortTh key={h.col} label={t(h.label)} col={h.col} numeric={h.numeric} center={h.center}
                            sort={userSort} setSort={setUserSort} />
                  ))}
                </tr>
              </thead>
              <tbody>
                {sortedUsers.map((u: UserRow) => {
                  const equity = equityOf(u);
                  const totalPnl = totalPnlOf(u);
                  return (
                  <tr key={u.address}>
                    <td title={u.address || undefined}>
                      <div>
                        {u.email || short(u.address)}
                        {u.depositWallet && <span style={{ opacity: 0.5 }}> ยท DW</span>}
                      </div>
                      {u.email && (
                        <div style={{ opacity: 0.5, fontSize: 12 }}>{short(u.address)}</div>
                      )}
                    </td>
                    <td style={{ textAlign: 'center', fontSize: 16 }} title={u.country || 'unknown'}>{flag(u.country) || <span style={{ opacity: 0.4 }}>โ€”</span>}</td>
                    <td>{fmtDate(u.createdAt)}</td>
                    <td>{fmtDate(u.lastLogin)}</td>
                    <td style={{ textAlign: 'right' }}>{u.activeSubs}</td>
                    <td style={{ textAlign: 'right' }}>{u.openPositions}</td>
                    <td style={{ textAlign: 'right' }}>{u.closedPositions}</td>
                    <td style={{ textAlign: 'right' }}>{u.depositBalance == null ? 'โ€”' : fmtUsd(u.depositBalance)}</td>
                    <td style={{ textAlign: 'right' }}>{fmtUsd(u.positionsValue)}</td>
                    <td style={{ textAlign: 'right' }}>{fmtUsd(equity)}</td>
                    <td style={{ textAlign: 'right' }}>{fmtUsd(u.notional)}</td>
                    <td style={{ textAlign: 'right' }} title={t('ua.builderFeesTip')}>{fmtUsd(builderFeesOf(u))}</td>
                    <td style={{ textAlign: 'right' }} className={u.realizedPnl >= 0 ? 'text-profit' : 'text-loss'}>
                      {fmtUsd(u.realizedPnl)}
                    </td>
                    <td style={{ textAlign: 'right' }} className={u.unrealizedPnl >= 0 ? 'text-profit' : 'text-loss'}>
                      {fmtUsd(u.unrealizedPnl)}
                    </td>
                    <td style={{ textAlign: 'right' }} className={totalPnl >= 0 ? 'text-profit' : 'text-loss'}>
                      {fmtUsd(totalPnl)}
                    </td>
                  </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        </>
      )}

      {/* Empty state */}
      {!loading && users.length === 0 && leaders.length === 0 && (
        <div className="portfolio-empty">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" width="36" height="36">
            <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
            <circle cx="9" cy="7" r="4" />
            <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
          </svg>
          <p>{t('ua.emptyTitle')}</p>
          <p style={{ fontSize: 13, opacity: 0.6 }}>{t('ua.emptyDesc')}</p>
        </div>
      )}

    </div>
  );
}

function KpiCard({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: boolean }) {
  return (
    <div className={`analytics-kpi-card${accent ? ' analytics-kpi-accent' : ''}`}>
      <span className="analytics-kpi-label">{label}</span>
      <span className="analytics-kpi-value">{value}</span>
      {sub && <span className="analytics-kpi-sub">{sub}</span>}
    </div>
  );
}

๐Ÿ“œ Git History

6c47fa4chore: local Polikopi project home + Phase 1 redesign artifacts12 days ago
Show last diff
Loading...