Menu

Change language

GitHub repo

Crypto Wallet Portfolio

blockfinance

Coinbase and Phantom-style multi-asset crypto holdings dashboard: treasury header with address copy and network badge, portfolio balance hero with 24h PnL and multi-asset allocation bar, asset holdings table with real-time token metrics, and an interactive quick swap calculator.

Installation

$npx uipkge-ng@latest add crypto-wallet-portfolio
Manifest:https://uipkge.dev/r/angular/crypto-wallet-portfolio.jsonInstalls to:src/app/components/blocks/crypto-wallet-portfolio/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

CryptoAsset
interface CryptoAsset {
  id: string
  name: string
  symbol: string
  price: string
  rawPrice: number
  change24h: string
  isPositive: boolean
  balance: string
  rawBalance: number
  fiatValue: string
  allocation: string
  color: string
}

Files installed (2)

  • src/app/components/blocks/crypto-wallet-portfolio/crypto-wallet-portfolio.component.ts32.7 kB
    // Coinbase / Phantom-style multi-asset crypto holdings dashboard: treasury header
    // with address copy and network badge, portfolio balance hero with 24h PnL and a
    // multi-asset allocation bar, an asset holdings table, and an interactive quick
    // swap calculator. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      ArrowDownLeft,
      ArrowDownUp,
      ArrowLeftRight,
      ArrowUpRight,
      Check,
      Copy,
      Fuel,
      LucideAngularModule,
      RefreshCw,
      ShieldCheck,
      TrendingDown,
      TrendingUp,
      Wallet,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export interface CryptoAsset {
      id: string
      name: string
      symbol: string
      price: string
      rawPrice: number
      change24h: string
      isPositive: boolean
      balance: string
      rawBalance: number
      fiatValue: string
      allocation: string
      color: string
    }
    
    export const cryptoAssets: CryptoAsset[] = [
      {
        id: 'eth',
        name: 'Ethereum',
        symbol: 'ETH',
        price: '$3,450.00',
        rawPrice: 3450.0,
        change24h: '+3.2%',
        isPositive: true,
        balance: '24.5 ETH',
        rawBalance: 24.5,
        fiatValue: '$84,525.00',
        allocation: '56.8%',
        color: '#627EEA',
      },
      {
        id: 'btc',
        name: 'Bitcoin',
        symbol: 'BTC',
        price: '$64,200.00',
        rawPrice: 64200.0,
        change24h: '+1.8%',
        isPositive: true,
        balance: '0.58 BTC',
        rawBalance: 0.58,
        fiatValue: '$37,236.00',
        allocation: '25.0%',
        color: '#F7931A',
      },
      {
        id: 'sol',
        name: 'Solana',
        symbol: 'SOL',
        price: '$148.50',
        rawPrice: 148.5,
        change24h: '-1.1%',
        isPositive: false,
        balance: '120.4 SOL',
        rawBalance: 120.4,
        fiatValue: '$17,879.40',
        allocation: '12.0%',
        color: '#14F195',
      },
      {
        id: 'usdc',
        name: 'USD Coin',
        symbol: 'USDC',
        price: '$1.00',
        rawPrice: 1.0,
        change24h: '+0.01%',
        isPositive: true,
        balance: '7,450.00 USDC',
        rawBalance: 7450.0,
        fiatValue: '$7,450.00',
        allocation: '5.0%',
        color: '#2775CA',
      },
      {
        id: 'link',
        name: 'Chainlink',
        symbol: 'LINK',
        price: '$18.30',
        rawPrice: 18.3,
        change24h: '+5.4%',
        isPositive: true,
        balance: '100.0 LINK',
        rawBalance: 100.0,
        fiatValue: '$1,830.10',
        allocation: '1.2%',
        color: '#375BD2',
      },
    ]
    
    export const walletFullAddress = '0x71C856402244243b92834b9d09c2534575823a9F'
    export const walletShortAddress = '0x71C...3a9F'
    
    const slippageOptions = ['0.1', '0.5', '1.0']
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-crypto-wallet-portfolio, [ui-crypto-wallet-portfolio]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiSeparatorComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="crypto-wallet-portfolio" [class]="rootClass">
          <!-- Header Section -->
          <header class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div class="space-y-1.5">
              <div class="flex flex-wrap items-center gap-2.5">
                <div
                  class="bg-primary/10 text-primary border-primary/20 flex size-9 shrink-0 items-center justify-center rounded-lg border shadow-xs"
                  aria-hidden="true"
                >
                  <lucide-icon [img]="Wallet" class="size-5" />
                </div>
                <h1 class="text-foreground text-2xl font-bold tracking-tight">Main Treasury Vault</h1>
                <span ui-badge variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 font-normal">
                  <span class="bg-success size-1.5 animate-pulse rounded-full" aria-hidden="true"></span>
                  Ethereum Mainnet
                </span>
              </div>
    
              <div class="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
                <span class="bg-muted/70 text-foreground border-border/80 rounded border px-2 py-0.5 font-mono font-medium">
                  {{ shortAddress }}
                </span>
                <button
                  ui-button
                  variant="ghost"
                  size="icon-sm"
                  class="text-muted-foreground hover:text-foreground size-6"
                  aria-label="Copy wallet address"
                  (click)="copyAddress()"
                >
                  @if (copied()) {
                    <lucide-icon [img]="Check" class="text-success size-3.5" />
                  } @else {
                    <lucide-icon [img]="Copy" class="size-3.5" />
                  }
                </button>
                <span class="inline-flex items-center gap-1">
                  <lucide-icon [img]="ShieldCheck" class="text-primary size-3.5" />
                  Multi-Sig (3 of 5)
                </span>
              </div>
            </div>
    
            <div class="flex flex-wrap items-center gap-2">
              <button ui-button variant="outline" size="sm" class="gap-1.5 shadow-xs">
                <lucide-icon [img]="ArrowUpRight" class="size-4" />
                Send
              </button>
              <button ui-button variant="outline" size="sm" class="gap-1.5 shadow-xs">
                <lucide-icon [img]="ArrowDownLeft" class="size-4" />
                Receive
              </button>
              <button ui-button size="sm" class="gap-1.5 shadow-xs">
                <lucide-icon [img]="ArrowLeftRight" class="size-4" />
                Swap
              </button>
            </div>
          </header>
    
          <!-- Main Grid: Portfolio Overview + Holdings Table & Quick Swap Calculator -->
          <div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
            <!-- Left Column: Hero Portfolio Card & Holdings Table (8 cols) -->
            <div class="space-y-6 lg:col-span-8">
              <!-- Hero Portfolio Balance Card -->
              <div ui-card class="border-border overflow-hidden shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <p ui-card-description class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
                      Total Portfolio Value
                    </p>
                    <div class="text-muted-foreground flex items-center gap-1.5 text-xs">
                      <span class="bg-success size-2 rounded-full" aria-hidden="true"></span>
                      Live Oracle Feed
                    </div>
                  </div>
                  <div class="mt-2 flex flex-wrap items-baseline gap-3">
                    <span class="text-foreground text-3xl font-bold tracking-tight tabular-nums sm:text-4xl">
                      $148,920.50
                    </span>
                    <div
                      class="border-success/20 bg-success/10 text-success text-success inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold tabular-nums"
                    >
                      <lucide-icon [img]="TrendingUp" class="size-3.5" />
                      +$6,420.10 (+4.51%)
                      <span class="text-muted-foreground ml-0.5 font-normal">24h</span>
                    </div>
                  </div>
                </div>
                <div ui-card-content class="space-y-4 pt-1">
                  <!-- Stacked Allocation Bar -->
                  <div class="space-y-2">
                    <div class="text-muted-foreground flex items-center justify-between text-xs">
                      <span class="text-foreground font-medium">Asset Allocation</span>
                      <span class="tabular-nums">5 Assets</span>
                    </div>
                    <div
                      class="bg-muted/60 border-border/50 flex h-3 w-full gap-0.5 overflow-hidden rounded-full border p-0.5"
                    >
                      @for (asset of assets; track asset.id) {
                        <div
                          [style.width]="asset.allocation"
                          [style.backgroundColor]="asset.color"
                          class="h-full transition-opacity duration-300 first:rounded-l-full last:rounded-r-full hover:opacity-90"
                          [attr.title]="asset.name + ' (' + asset.symbol + '): ' + asset.allocation"
                        ></div>
                      }
                    </div>
    
                    <!-- Allocation Legend -->
                    <div class="flex flex-wrap items-center gap-x-4 gap-y-2 pt-1 text-xs">
                      @for (asset of assets; track asset.id) {
                        <div class="flex items-center gap-1.5">
                          <span
                            class="size-2.5 shrink-0 rounded-full shadow-xs"
                            [style.backgroundColor]="asset.color"
                            aria-hidden="true"
                          ></span>
                          <span class="text-foreground font-medium">{{ asset.symbol }}</span>
                          <span class="text-muted-foreground tabular-nums">{{ asset.allocation }}</span>
                        </div>
                      }
                    </div>
                  </div>
                </div>
              </div>
    
              <!-- Asset Holdings Table Card -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <div>
                      <h3 ui-card-title class="text-foreground text-base font-semibold">Asset Holdings</h3>
                      <p ui-card-description class="text-muted-foreground text-xs">
                        Real-time token prices, balances, and portfolio share.
                      </p>
                    </div>
                    <span ui-badge variant="secondary" class="text-xs font-normal tabular-nums"> 5 Assets </span>
                  </div>
                </div>
                <div ui-card-content class="p-0">
                  <div class="overflow-x-auto">
                    <table ui-table>
                      <thead ui-table-header>
                        <tr ui-table-row>
                          <th ui-table-head class="pl-6">Asset</th>
                          <th ui-table-head class="text-right">Price (24h)</th>
                          <th ui-table-head class="text-right">Holdings</th>
                          <th ui-table-head class="hidden text-right sm:table-cell">Portfolio Share</th>
                          <th ui-table-head class="pr-6 text-right">Actions</th>
                        </tr>
                      </thead>
                      <tbody ui-table-body>
                        @for (asset of assets; track asset.id) {
                          <tr ui-table-row class="hover:bg-muted/40 transition-colors">
                            <!-- Token Icon, Name, Symbol -->
                            <td ui-table-cell class="py-3.5 pl-6">
                              <div class="flex items-center gap-3">
                                @if (asset.id === 'eth') {
                                  <svg
                                    viewBox="0 0 32 32"
                                    class="size-8 shrink-0 rounded-full shadow-xs"
                                    aria-hidden="true"
                                  >
                                    <circle cx="16" cy="16" r="16" fill="#627EEA" />
                                    <path fill="#ffffff" fill-opacity="0.6" d="M16.5 4v8.87l7.5 3.35z" />
                                    <path fill="#ffffff" d="M16.5 4L9 16.22l7.5-3.35z" />
                                    <path fill="#ffffff" fill-opacity="0.6" d="M16.5 21.97v6.03L24 17.62z" />
                                    <path fill="#ffffff" d="M16.5 28v-6.03L9 17.62z" />
                                    <path fill="#ffffff" fill-opacity="0.2" d="M16.5 20.57l7.5-4.35-7.5-3.35z" />
                                    <path fill="#ffffff" fill-opacity="0.6" d="M9 16.22l7.5 4.35v-7.7z" />
                                  </svg>
                                } @else if (asset.id === 'btc') {
                                  <svg
                                    viewBox="0 0 32 32"
                                    class="size-8 shrink-0 rounded-full shadow-xs"
                                    aria-hidden="true"
                                  >
                                    <circle cx="16" cy="16" r="16" fill="#F7931A" />
                                    <path
                                      fill="#ffffff"
                                      d="M22.5 13.5c.3-1.9-1.2-2.9-3.2-3.6l.7-2.6-1.6-.4-.6 2.5c-.4-.1-.9-.2-1.3-.3l.6-2.5-1.6-.4-.7 2.6c-.3-.1-.7-.2-1-.2l-2.2-.5-.4 1.7s.9.2.9.2c.5.1.6.4.6.6l-.6 2.5c0 0 .1 0 .2 0l-.2 0-.9 3.5c-.1.2-.3.4-.6.3 0 0-.9-.2-.9-.2l-.8 1.8 2.1.5c.4.1.8.2 1.2.3l-.7 2.7 1.6.4.7-2.6c.4.1.9.2 1.3.3l-.7 2.7 1.6.4.7-2.7c2.8.5 4.8.3 5.7-2.2.7-2-.1-3.1-1.5-3.8 1.1-.3 1.8-1 2-2.5zm-3.6 5.4c-.5 2-3.9.9-5 .6l.9-3.6c1.1.3 4.6.8 4.1 3zm.5-5.6c-.5 1.8-3.3.9-4.2.7l.8-3.3c.9.2 3.8.7 3.4 2.6z"
                                    />
                                  </svg>
                                } @else if (asset.id === 'sol') {
                                  <svg
                                    viewBox="0 0 32 32"
                                    class="size-8 shrink-0 rounded-full shadow-xs"
                                    aria-hidden="true"
                                  >
                                    <circle cx="16" cy="16" r="16" fill="#14151a" />
                                    <path
                                      fill="#14F195"
                                      d="M8.5 21.8l1.8-1.8c.3-.3.7-.5 1.1-.5h10.4c.5 0 .9.6.6 1l-1.8 1.8c-.3.3-.7.5-1.1.5H9.1c-.5 0-.9-.6-.6-1z"
                                    />
                                    <path
                                      fill="#9945FF"
                                      d="M8.5 12.8l1.8-1.8c.3-.3.7-.5 1.1-.5h10.4c.5 0 .9.6.6 1l-1.8 1.8c-.3.3-.7.5-1.1.5H9.1c-.5 0-.9-.6-.6-1z"
                                    />
                                    <path
                                      fill="#00C2FF"
                                      d="M23.5 17.3l-1.8 1.8c-.3.3-.7.5-1.1.5H10.2c-.5 0-.9-.6-.6-1l1.8-1.8c.3-.3.7-.5 1.1-.5h10.4c.5 0 .9.6.6 1z"
                                    />
                                  </svg>
                                } @else if (asset.id === 'usdc') {
                                  <svg
                                    viewBox="0 0 32 32"
                                    class="size-8 shrink-0 rounded-full shadow-xs"
                                    aria-hidden="true"
                                  >
                                    <circle cx="16" cy="16" r="16" fill="#2775CA" />
                                    <path
                                      fill="#ffffff"
                                      d="M16 6a10 10 0 1010 10A10.01 10.01 0 0016 6zm0 18a8 8 0 118-8 8.01 8.01 0 01-8 8zm1-12h-2v1.1a3.5 3.5 0 00-2 3.1c0 2 1.5 2.7 3 3.1 1.2.3 1.8.6 1.8 1.2s-.6 1.1-1.6 1.1a3.4 3.4 0 01-2.4-.9l-.8 1.4a4.8 4.8 0 003 1.1V20h2v-1.1a3.4 3.4 0 002-3.1c0-2.1-1.6-2.8-3.1-3.2-1.1-.3-1.7-.6-1.7-1.1 0-.5.5-1 1.5-1a3.1 3.1 0 012 .7l.8-1.4a4.5 4.5 0 00-2.5-.9z"
                                    />
                                  </svg>
                                } @else {
                                  <svg
                                    viewBox="0 0 32 32"
                                    class="size-8 shrink-0 rounded-full shadow-xs"
                                    aria-hidden="true"
                                  >
                                    <circle cx="16" cy="16" r="16" fill="#375BD2" />
                                    <path
                                      fill="#ffffff"
                                      d="M16 7l-7.8 4.5v9L16 25l7.8-4.5v-9L16 7zm5.2 12l-5.2 3-5.2-3v-6l5.2-3 5.2 3v6z"
                                    />
                                  </svg>
                                }
                                <div>
                                  <div class="text-foreground flex items-center gap-1.5 text-sm font-medium">
                                    {{ asset.name }}
                                  </div>
                                  <div class="text-muted-foreground font-mono text-xs">{{ asset.symbol }}</div>
                                </div>
                              </div>
                            </td>
    
                            <!-- Current Price & 24h Change -->
                            <td ui-table-cell class="py-3.5 text-right">
                              <div class="text-foreground text-sm font-medium tabular-nums">{{ asset.price }}</div>
                              <div [class]="trendClass(asset)">
                                @if (asset.isPositive) {
                                  <lucide-icon [img]="TrendingUp" class="size-3" />
                                } @else {
                                  <lucide-icon [img]="TrendingDown" class="size-3" />
                                }
                                {{ asset.change24h }}
                              </div>
                            </td>
    
                            <!-- Holdings Amount & Fiat Value -->
                            <td ui-table-cell class="py-3.5 text-right">
                              <div class="text-foreground text-sm font-medium tabular-nums">{{ asset.balance }}</div>
                              <div class="text-muted-foreground text-xs tabular-nums">{{ asset.fiatValue }}</div>
                            </td>
    
                            <!-- Portfolio Share -->
                            <td ui-table-cell class="hidden py-3.5 text-right sm:table-cell">
                              <span ui-badge variant="outline" class="border-border/80 font-mono text-xs tabular-nums">
                                {{ asset.allocation }}
                              </span>
                            </td>
    
                            <!-- Actions -->
                            <td ui-table-cell class="py-3.5 pr-6 text-right">
                              <div class="flex items-center justify-end gap-1.5">
                                <button
                                  ui-button
                                  variant="outline"
                                  size="sm"
                                  class="h-7 px-2.5 text-xs shadow-xs"
                                  (click)="selectTradePair(asset.symbol)"
                                >
                                  Trade
                                </button>
                                <button
                                  ui-button
                                  variant="ghost"
                                  size="sm"
                                  class="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
                                >
                                  Transfer
                                </button>
                              </div>
                            </td>
                          </tr>
                        }
                      </tbody>
                    </table>
                  </div>
                </div>
              </div>
            </div>
    
            <!-- Right Column: Quick Swap Widget & Security Info (4 cols) -->
            <div class="space-y-6 lg:col-span-4">
              <!-- Quick Swap Card -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-3">
                  <div class="flex items-center justify-between">
                    <h3 ui-card-title class="text-foreground flex items-center gap-1.5 text-base font-semibold">
                      <lucide-icon [img]="RefreshCw" class="text-primary size-4" />
                      Quick Swap
                    </h3>
                    <!-- Slippage Settings -->
                    <div class="flex items-center gap-1">
                      @for (opt of slippageOptions; track opt) {
                        <button type="button" [class]="slippageClass(opt)" (click)="slippage.set(opt)">{{ opt }}%</button>
                      }
                    </div>
                  </div>
                  <p ui-card-description class="text-muted-foreground text-xs">
                    Swap instant liquidity across decentralized pools.
                  </p>
                </div>
    
                <div ui-card-content class="space-y-3">
                  <!-- From Token Box -->
                  <div class="bg-muted/40 border-border/80 space-y-2 rounded-lg border p-3">
                    <div class="text-muted-foreground flex items-center justify-between text-xs">
                      <label for="swap-pay-input" class="font-medium">You Pay</label>
                      <div class="flex items-center gap-1.5">
                        <span class="tabular-nums">Balance: {{ fromAsset().balance }}</span>
                        <button
                          type="button"
                          class="text-primary focus-visible:ring-ring min-h-6 rounded text-xs font-semibold uppercase hover:underline focus-visible:ring-1 focus-visible:outline-none"
                          (click)="setMaxPay()"
                        >
                          MAX
                        </button>
                      </div>
                    </div>
    
                    <div class="flex items-center justify-between gap-3">
                      <ui-input
                        id="swap-pay-input"
                        [value]="payAmount()"
                        (valueChange)="payAmount.set($event)"
                        type="number"
                        step="any"
                        min="0"
                        class="h-8 border-none px-0 text-lg font-semibold tabular-nums shadow-none focus-within:ring-0"
                        placeholder="0.0"
                      />
    
                      <!-- From Token Selector Pill -->
                      <div
                        class="bg-background border-border flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 shadow-xs select-none"
                      >
                        <span
                          class="size-2 shrink-0 rounded-full"
                          [style.backgroundColor]="fromAsset().color"
                          aria-hidden="true"
                        ></span>
                        <span class="text-foreground text-xs font-semibold">{{ fromAsset().symbol }}</span>
                      </div>
                    </div>
    
                    <div class="text-muted-foreground text-xs tabular-nums">≈ {{ fiatPayFormatted() }}</div>
                  </div>
    
                  <!-- Invert Flip Button -->
                  <div class="relative z-10 -my-1 flex justify-center">
                    <button
                      ui-button
                      variant="outline"
                      size="icon-sm"
                      class="border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground size-8 rounded-full shadow-xs transition-[color,background-color,border-color,box-shadow,opacity,transform,scale,translate,rotate] active:scale-95"
                      aria-label="Flip swap token pair"
                      (click)="flipTokens()"
                    >
                      <lucide-icon [img]="ArrowDownUp" class="size-4" />
                    </button>
                  </div>
    
                  <!-- To Token Box -->
                  <div class="bg-muted/40 border-border/80 space-y-2 rounded-lg border p-3">
                    <div class="text-muted-foreground flex items-center justify-between text-xs">
                      <span class="font-medium">You Receive</span>
                      <span class="tabular-nums">Balance: {{ toAsset().balance }}</span>
                    </div>
    
                    <div class="flex items-center justify-between gap-3">
                      <div class="text-foreground flex h-8 items-center text-lg font-semibold tabular-nums">
                        {{ calculatedReceive() }}
                      </div>
    
                      <!-- To Token Selector Pill -->
                      <div
                        class="bg-background border-border flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 shadow-xs select-none"
                      >
                        <span
                          class="size-2 shrink-0 rounded-full"
                          [style.backgroundColor]="toAsset().color"
                          aria-hidden="true"
                        ></span>
                        <span class="text-foreground text-xs font-semibold">{{ toAsset().symbol }}</span>
                      </div>
                    </div>
    
                    <div class="text-muted-foreground text-xs tabular-nums">≈ {{ fiatReceiveFormatted() }}</div>
                  </div>
    
                  <!-- Trade Details Summary -->
                  <div
                    class="border-border/60 bg-muted/20 text-muted-foreground space-y-1.5 rounded-md border p-2.5 text-xs"
                  >
                    <div class="flex items-center justify-between">
                      <span>Exchange Rate</span>
                      <span class="text-foreground font-medium tabular-nums">{{ exchangeRateFormatted() }}</span>
                    </div>
                    <div class="flex items-center justify-between">
                      <span class="flex items-center gap-1">
                        <lucide-icon [img]="Fuel" class="text-muted-foreground size-3" />
                        Est. Network Gas
                      </span>
                      <span class="text-foreground font-medium tabular-nums">~$3.40 (Fast)</span>
                    </div>
                    <div class="flex items-center justify-between">
                      <span>Slippage Tolerance</span>
                      <span class="text-foreground font-medium tabular-nums">{{ slippage() }}%</span>
                    </div>
                    <div class="flex items-center justify-between">
                      <span>Price Impact</span>
                      <span class="text-success text-success font-medium tabular-nums">&lt; 0.01%</span>
                    </div>
                  </div>
    
                  <!-- Execute Swap Button -->
                  <button
                    ui-button
                    class="w-full font-semibold shadow-xs"
                    size="lg"
                    [disabled]="swapDisabled"
                    (click)="handleExecuteSwap()"
                  >
                    @if (isSwapping()) {
                      <lucide-icon [img]="RefreshCw" class="size-4 animate-spin" />
                    } @else if (swapSuccess()) {
                      <lucide-icon [img]="Check" class="text-success size-4" />
                    }
                    @if (isSwapping()) {
                      <span>Routing Transaction...</span>
                    } @else if (swapSuccess()) {
                      <span>Swap Confirmed!</span>
                    } @else {
                      <span>Swap {{ fromAsset().symbol }} for {{ toAsset().symbol }}</span>
                    }
                  </button>
    
                  <p class="text-muted-foreground text-center text-xs">
                    Direct routing via Uniswap v3 & Curve liquidity pools.
                  </p>
                </div>
              </div>
    
              <!-- Security & Vault Status Summary -->
              <div ui-card class="border-border shadow-xs">
                <div ui-card-header class="pb-2">
                  <h3 ui-card-title class="text-foreground flex items-center gap-1.5 text-sm font-semibold">
                    <lucide-icon [img]="ShieldCheck" class="text-success size-4" />
                    Vault Security & Policies
                  </h3>
                </div>
                <div ui-card-content class="text-muted-foreground space-y-3 text-xs">
                  <div class="flex items-center justify-between">
                    <span>Threshold Policy</span>
                    <span class="text-foreground font-mono font-medium">3 / 5 Approvals</span>
                  </div>
                  <div ui-separator class="my-1"></div>
                  <div class="flex items-center justify-between">
                    <span>Daily Spend Limit</span>
                    <span class="text-foreground font-medium tabular-nums">$500,000.00</span>
                  </div>
                  <div ui-separator class="my-1"></div>
                  <div class="flex items-center justify-between">
                    <span>Hardware Key Modules</span>
                    <span ui-badge variant="outline" class="border-success/30 text-success text-xs font-normal">
                      5 Ledger HSMs Active
                    </span>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiCryptoWalletPortfolioComponent {
      protected readonly ArrowDownLeft = ArrowDownLeft
      protected readonly ArrowDownUp = ArrowDownUp
      protected readonly ArrowLeftRight = ArrowLeftRight
      protected readonly ArrowUpRight = ArrowUpRight
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Fuel = Fuel
      protected readonly RefreshCw = RefreshCw
      protected readonly ShieldCheck = ShieldCheck
      protected readonly TrendingDown = TrendingDown
      protected readonly TrendingUp = TrendingUp
      protected readonly Wallet = Wallet
    
      protected readonly assets = cryptoAssets
      protected readonly shortAddress = walletShortAddress
      protected readonly slippageOptions = slippageOptions
    
      @Input('class') className?: string
    
      readonly copied = signal(false)
      readonly fromTokenSymbol = signal('ETH')
      readonly toTokenSymbol = signal('USDC')
      readonly payAmount = signal('1.5')
      readonly slippage = signal('0.5')
      readonly isSwapping = signal(false)
      readonly swapSuccess = signal(false)
    
      private copyTimer: ReturnType<typeof setTimeout> | undefined
      private swapTimers: ReturnType<typeof setTimeout>[] = []
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          clearTimeout(this.copyTimer)
          for (const t of this.swapTimers) clearTimeout(t)
        })
      }
    
      // Quick Swap State
      readonly tokenMap = computed(() => {
        const map: Record<string, CryptoAsset> = {}
        for (const asset of cryptoAssets) map[asset.symbol] = asset
        return map
      })
    
      readonly fromAsset = computed(() => this.tokenMap()[this.fromTokenSymbol()] ?? cryptoAssets[0]!)
      readonly toAsset = computed(() => this.tokenMap()[this.toTokenSymbol()] ?? cryptoAssets[3]!)
    
      readonly calculatedReceive = computed(() => {
        const amount = parseFloat(this.payAmount()) || 0
        if (amount <= 0) return '0.00'
        const fromFiat = amount * this.fromAsset().rawPrice
        const receiveUnits = fromFiat / this.toAsset().rawPrice
        if (this.toAsset().rawPrice >= 100) return receiveUnits.toFixed(4)
        return receiveUnits.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
      })
    
      readonly fiatPayFormatted = computed(() =>
        ((parseFloat(this.payAmount()) || 0) * this.fromAsset().rawPrice).toLocaleString('en-US', {
          style: 'currency',
          currency: 'USD',
        }),
      )
    
      readonly fiatReceiveFormatted = computed(() =>
        ((parseFloat(this.payAmount()) || 0) * this.fromAsset().rawPrice).toLocaleString('en-US', {
          style: 'currency',
          currency: 'USD',
        }),
      )
    
      readonly exchangeRateFormatted = computed(() => {
        const rate = this.fromAsset().rawPrice / this.toAsset().rawPrice
        const formattedRate =
          rate > 100
            ? rate.toLocaleString('en-US', { maximumFractionDigits: 2 })
            : rate.toLocaleString('en-US', { maximumFractionDigits: 6 })
        return `1 ${this.fromAsset().symbol} ≈ ${formattedRate} ${this.toAsset().symbol}`
      })
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-6xl space-y-6', this.className)
      }
    
      get swapDisabled(): boolean {
        return this.isSwapping() || (parseFloat(this.payAmount()) || 0) <= 0
      }
    
      trendClass(asset: CryptoAsset): string {
        return cn(
          'flex items-center justify-end gap-0.5 text-xs font-medium tabular-nums',
          asset.isPositive ? 'text-success' : 'text-destructive',
        )
      }
    
      slippageClass(opt: string): string {
        return cn(
          'focus-visible:ring-ring min-h-6 rounded px-1.5 py-0.5 font-mono text-xs transition-colors focus-visible:ring-1 focus-visible:outline-none',
          this.slippage() === opt
            ? 'bg-primary text-primary-foreground font-semibold'
            : 'text-muted-foreground hover:text-foreground bg-muted/60',
        )
      }
    
      async copyAddress(): Promise<void> {
        try {
          await navigator.clipboard.writeText(walletFullAddress)
          this.copied.set(true)
          clearTimeout(this.copyTimer)
          this.copyTimer = setTimeout(() => this.copied.set(false), 2000)
        } catch {
          // Clipboard fallback
        }
      }
    
      selectTradePair(symbol: string): void {
        this.fromTokenSymbol.set(symbol)
        this.toTokenSymbol.set(symbol === 'USDC' ? 'ETH' : 'USDC')
      }
    
      flipTokens(): void {
        const from = this.fromTokenSymbol()
        this.fromTokenSymbol.set(this.toTokenSymbol())
        this.toTokenSymbol.set(from)
      }
    
      setMaxPay(): void {
        this.payAmount.set(String(this.fromAsset().rawBalance))
      }
    
      handleExecuteSwap(): void {
        this.isSwapping.set(true)
        this.swapTimers.push(
          setTimeout(() => {
            this.isSwapping.set(false)
            this.swapSuccess.set(true)
            this.swapTimers.push(setTimeout(() => this.swapSuccess.set(false), 3000))
          }, 1200),
        )
      }
    }
    
  • src/app/components/blocks/crypto-wallet-portfolio/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/crypto-wallet-portfolio.json