Menu

Change language

GitHub repo

Currency Converter Widget

blockfinance

Wise and Revolut-style real-time multi-currency exchange calculator and transfer fee estimator with live mid-market rates, transparent fee breakdown rail, bank savings comparison, and quick-rate ticker.

Installation

$npx uipkge-ng@latest add currency-converter-widget
Manifest:https://uipkge.dev/r/angular/currency-converter-widget.jsonInstalls to:src/app/components/blocks/currency-converter-widget/

Variants

Loading interactive previews…

Schema

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

CurrencyOption
interface CurrencyOption {
  code: string
  name: string
  symbol: string
  flag: string
  rateToBase: number // USD base = 1.0
  deliverySpeed: string
  deliveryNetwork: string
}
PopularPair
interface PopularPair {
  id: string
  from: string
  to: string
  change24h: number
  trend: 'up' | 'down'
}
TransferPayload
interface TransferPayload {
  sendAmount: number
  fromCurrency: string
  toCurrency: string
  recipientGets: number
  fee: number
  rate: number
}

Files installed (2)

  • src/app/components/blocks/currency-converter-widget/currency-converter-widget.component.ts25.9 kB
    // Wise and Revolut-style real-time multi-currency exchange calculator and transfer fee
    // estimator with live mid-market rates, transparent fee breakdown rail, bank savings
    // comparison, and quick-rate ticker. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core'
    import {
      ArrowDownRight,
      ArrowRight,
      ArrowUpDown,
      ArrowUpRight,
      Building2,
      Coins,
      Lock,
      LucideAngularModule,
      Minus,
      RefreshCw,
      ShieldCheck,
      Zap,
    } 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 } from '@/ui/card/card.component'
    import {
      UiSelectComponent,
      UiSelectContentComponent,
      UiSelectItemComponent,
      UiSelectTriggerComponent,
      UiSelectValueComponent,
    } from '@/ui/select/select.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    
    export interface CurrencyOption {
      code: string
      name: string
      symbol: string
      flag: string
      rateToBase: number // USD base = 1.0
      deliverySpeed: string
      deliveryNetwork: string
    }
    
    export interface PopularPair {
      id: string
      from: string
      to: string
      change24h: number
      trend: 'up' | 'down'
    }
    
    export interface TransferPayload {
      sendAmount: number
      fromCurrency: string
      toCurrency: string
      recipientGets: number
      fee: number
      rate: number
    }
    
    const CURRENCIES: CurrencyOption[] = [
      {
        code: 'USD',
        name: 'US Dollar',
        symbol: '$',
        flag: '🇺🇸',
        rateToBase: 1.0,
        deliverySpeed: 'in minutes',
        deliveryNetwork: 'FedNow / RTP',
      },
      {
        code: 'EUR',
        name: 'Euro',
        symbol: '€',
        flag: '🇪🇺',
        rateToBase: 0.9245,
        deliverySpeed: 'in minutes',
        deliveryNetwork: 'SEPA Instant',
      },
      {
        code: 'GBP',
        name: 'British Pound',
        symbol: '£',
        flag: '🇬🇧',
        rateToBase: 0.7892,
        deliverySpeed: 'in seconds',
        deliveryNetwork: 'Faster Payments',
      },
      {
        code: 'JPY',
        name: 'Japanese Yen',
        symbol: '¥',
        flag: '🇯🇵',
        rateToBase: 154.6,
        deliverySpeed: 'within 4 hours',
        deliveryNetwork: 'Zengin System',
      },
      {
        code: 'CAD',
        name: 'Canadian Dollar',
        symbol: 'CA$',
        flag: '🇨🇦',
        rateToBase: 1.378,
        deliverySpeed: 'in minutes',
        deliveryNetwork: 'Interac Direct',
      },
      {
        code: 'AUD',
        name: 'Australian Dollar',
        symbol: 'A$',
        flag: '🇦🇺',
        rateToBase: 1.523,
        deliverySpeed: 'in minutes',
        deliveryNetwork: 'NPP / PayID',
      },
      {
        code: 'SGD',
        name: 'Singapore Dollar',
        symbol: 'S$',
        flag: '🇸🇬',
        rateToBase: 1.348,
        deliverySpeed: 'instant',
        deliveryNetwork: 'FAST Network',
      },
      {
        code: 'CHF',
        name: 'Swiss Franc',
        symbol: 'CHF',
        flag: '🇨🇭',
        rateToBase: 0.898,
        deliverySpeed: 'in minutes',
        deliveryNetwork: 'SIC Instant',
      },
    ]
    
    const POPULAR_PAIRS: PopularPair[] = [
      { id: 'eur-usd', from: 'EUR', to: 'USD', change24h: 0.18, trend: 'up' },
      { id: 'gbp-usd', from: 'GBP', to: 'USD', change24h: 0.24, trend: 'up' },
      { id: 'usd-jpy', from: 'USD', to: 'JPY', change24h: -0.32, trend: 'down' },
      { id: 'usd-cad', from: 'USD', to: 'CAD', change24h: 0.09, trend: 'up' },
      { id: 'usd-eur', from: 'USD', to: 'EUR', change24h: -0.18, trend: 'down' },
      { id: 'gbp-eur', from: 'GBP', to: 'EUR', change24h: 0.05, trend: 'up' },
    ]
    
    function formatAmount(val: number, currencyCode: string): string {
      const isZeroDecimal = currencyCode === 'JPY'
      return new Intl.NumberFormat('en-US', {
        minimumFractionDigits: isZeroDecimal ? 0 : 2,
        maximumFractionDigits: isZeroDecimal ? 0 : 2,
      }).format(val)
    }
    
    function getPairRate(fromCode: string, toCode: string): string {
      const f = CURRENCIES.find((c) => c.code === fromCode)?.rateToBase || 1
      const t = CURRENCIES.find((c) => c.code === toCode)?.rateToBase || 1
      const rate = t / f
      return rate >= 100 ? rate.toFixed(2) : rate.toFixed(4)
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-currency-converter-widget, [ui-currency-converter-widget]',
      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,
        UiInputComponent,
        UiSelectComponent,
        UiSelectContentComponent,
        UiSelectItemComponent,
        UiSelectTriggerComponent,
        UiSelectValueComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="currency-converter-widget" [class]="rootClass">
          <!-- Header Section -->
          <div class="space-y-2">
            <div class="flex flex-wrap items-center justify-between gap-2">
              <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
                Currency Converter & International Transfer
              </h2>
              <span ui-badge variant="outline" class="gap-1.5 py-1 text-xs font-normal">
                <span class="bg-success size-2 animate-pulse rounded-full"></span>
                Live Market Rates
              </span>
            </div>
            <p class="text-muted-foreground text-sm">
              Real-time mid-market exchange rates with transparent fees. No hidden markups.
            </p>
    
            <!-- Refresh & Current Mid-market rate banner -->
            <div
              class="border-border bg-muted/40 text-muted-foreground flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3.5 py-2 text-xs"
            >
              <div class="flex items-center gap-2">
                <span class="text-foreground font-medium">
                  Mid-market rate: 1 {{ fromCurrencyObj().code }} = {{ formattedExchangeRate() }}
                  {{ toCurrencyObj().code }}
                </span>
                <span>Updated 1m ago</span>
              </div>
              <button
                type="button"
                aria-label="Refresh exchange rates"
                class="text-foreground hover:text-primary focus-visible:ring-ring inline-flex min-h-6 items-center gap-1 rounded px-1.5 py-0.5 font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
                (click)="handleRefresh()"
              >
                <lucide-icon [img]="RefreshCw" [class]="refreshIconClass" />
                <span>{{ refreshSuccess() ? 'Refreshed!' : 'Refresh' }}</span>
              </button>
            </div>
          </div>
    
          <!-- Main Converter Card -->
          <div ui-card class="border-border shadow-xs">
            <div ui-card-content class="space-y-4 p-5 sm:p-6">
              <!-- Payment method tab pill -->
              <div class="flex items-center justify-between gap-2">
                <div class="border-border bg-muted/50 inline-flex rounded-lg border p-1 text-xs">
                  <button type="button" [class]="methodTabClass('bank')" (click)="paymentMethod.set('bank')">
                    Bank Transfer (Lowest Fee)
                  </button>
                  <button type="button" [class]="methodTabClass('card')" (click)="paymentMethod.set('card')">
                    Debit / Credit Card
                  </button>
                </div>
    
                <span class="text-muted-foreground hidden text-xs sm:inline-flex"> Guaranteed rate for 24h </span>
              </div>
    
              <!-- "You send" Input Container -->
              <div
                class="border-input bg-card focus-within:border-ring focus-within:ring-ring/20 rounded-xl border p-3.5 shadow-2xs transition-colors focus-within:ring-2"
              >
                <div class="text-muted-foreground mb-1.5 flex items-center justify-between text-xs">
                  <label for="angular-send-amount-input" class="text-foreground font-medium">You send</label>
                  <span>Max limit: $100,000 / transfer</span>
                </div>
    
                <div class="flex flex-wrap items-center gap-3">
                  <div class="flex flex-1 items-baseline gap-1">
                    <span class="text-muted-foreground text-xl font-semibold select-none sm:text-2xl">
                      {{ fromCurrencyObj().symbol }}
                    </span>
                    <ui-input
                      id="angular-send-amount-input"
                      type="number"
                      [min]="1"
                      [value]="sendAmount()"
                      (valueChange)="onAmountChange($event)"
                      placeholder="1,000.00"
                      class="text-foreground placeholder:text-muted-foreground/40 w-full bg-transparent text-2xl font-bold tracking-tight tabular-nums outline-none sm:text-3xl"
                    />
                  </div>
    
                  <!-- From Currency Selector -->
                  <div class="w-36 shrink-0">
                    <ui-select [value]="fromCurrency()" (valueChange)="fromCurrency.set($event)">
                      <button ui-select-trigger class="bg-muted/40 h-11 w-full text-xs font-medium sm:text-sm">
                        <ui-select-value placeholder="Select currency" />
                      </button>
                      <ui-select-content class="max-h-64">
                        @for (curr of currencies; track curr.code) {
                          <ui-select-item [value]="curr.code">
                            <div class="flex items-center gap-2">
                              <span class="text-base">{{ curr.flag }}</span>
                              <span class="font-semibold">{{ curr.code }}</span>
                              <span class="text-muted-foreground truncate text-xs">({{ curr.name }})</span>
                            </div>
                          </ui-select-item>
                        }
                      </ui-select-content>
                    </ui-select>
                  </div>
                </div>
              </div>
    
              <!-- Transfer Breakdown Rail (Vertical Connector) -->
              <div
                class="before:bg-border relative my-1 space-y-3.5 py-2 pl-6 before:absolute before:top-2 before:bottom-2 before:left-2.5 before:w-px"
              >
                <!-- Step 1: Fee -->
                <div class="relative flex items-center justify-between text-xs">
                  <div
                    class="border-border bg-card text-muted-foreground absolute -left-[19px] flex size-3.5 items-center justify-center rounded-full border"
                  >
                    <lucide-icon [img]="Minus" class="size-2.5" />
                  </div>
                  <div class="flex items-center gap-1.5">
                    <span class="text-foreground font-semibold tabular-nums">
                      -{{ fromCurrencyObj().symbol }}{{ formatAmount(totalFee(), fromCurrencyObj().code) }}
                      {{ fromCurrencyObj().code }}
                    </span>
                    <span class="text-muted-foreground">Transfer fee</span>
                    <span ui-badge variant="secondary" class="text-muted-foreground h-4.5 px-1.5 text-xs font-normal">
                      {{ paymentMethod() === 'card' ? '0.65% + $1.20' : '0.35% + $1.20' }}
                    </span>
                  </div>
                  <span class="text-muted-foreground hidden sm:inline">Transparent fixed rate</span>
                </div>
    
                <!-- Step 2: Amount to convert -->
                <div class="relative flex items-center justify-between text-xs">
                  <div
                    class="border-border bg-card text-muted-foreground absolute -left-[19px] flex size-3.5 items-center justify-center rounded-full border"
                  >
                    <span class="text-xs leading-none font-bold">=</span>
                  </div>
                  <div class="flex items-center gap-1.5">
                    <span class="text-foreground font-semibold tabular-nums">
                      {{ fromCurrencyObj().symbol }}{{ formatAmount(amountToConvert(), fromCurrencyObj().code) }}
                      {{ fromCurrencyObj().code }}
                    </span>
                    <span class="text-muted-foreground">Amount to convert</span>
                  </div>
                  <span class="text-muted-foreground hidden sm:inline">Net transfer base</span>
                </div>
    
                <!-- Step 3: Guaranteed Exchange Rate & Swap Button Line -->
                <div class="relative flex flex-wrap items-center justify-between gap-2 text-xs">
                  <div
                    class="border-primary/50 bg-primary text-primary-foreground absolute -left-[19px] flex size-3.5 items-center justify-center rounded-full border"
                  >
                    <lucide-icon [img]="Lock" class="size-2" />
                  </div>
                  <div class="flex flex-wrap items-center gap-2">
                    <span class="text-foreground font-semibold tabular-nums"> × {{ formattedExchangeRate() }} </span>
                    <span class="text-muted-foreground">Guaranteed mid-market rate</span>
                    <span
                      ui-badge
                      variant="outline"
                      class="border-success/30 bg-success/10 text-success gap-1 py-0.5 text-xs"
                    >
                      <lucide-icon [img]="Lock" class="size-2.5" />
                      24h Guarantee
                    </span>
                  </div>
    
                  <!-- Swap Currencies Button -->
                  <button
                    type="button"
                    aria-label="Swap currencies"
                    class="border-border bg-card text-foreground hover:bg-accent hover:border-primary/50 focus-visible:ring-ring inline-flex min-h-6 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium shadow-2xs transition-[background-color,border-color,box-shadow,scale] focus-visible:ring-2 focus-visible:outline-none active:scale-95"
                    (click)="handleSwap()"
                  >
                    <lucide-icon [img]="ArrowUpDown" class="text-primary size-3" />
                    <span>Swap</span>
                  </button>
                </div>
              </div>
    
              <!-- "Recipient gets" Input / Output Container -->
              <div
                class="border-input bg-card focus-within:border-ring focus-within:ring-ring/20 rounded-xl border p-3.5 shadow-2xs transition-colors focus-within:ring-2"
              >
                <div class="text-muted-foreground mb-1.5 flex items-center justify-between text-xs">
                  <label class="text-foreground font-medium">Recipient gets</label>
                  <span class="text-success flex items-center gap-1 font-medium">
                    <lucide-icon [img]="Zap" class="size-3" />
                    {{ toCurrencyObj().deliverySpeed }}
                  </span>
                </div>
    
                <div class="flex flex-wrap items-center gap-3">
                  <div class="flex min-w-0 flex-1 items-baseline gap-1">
                    <span class="text-muted-foreground text-xl font-semibold select-none sm:text-2xl">
                      {{ toCurrencyObj().symbol }}
                    </span>
                    <div
                      class="text-foreground w-full truncate text-2xl font-bold tracking-tight tabular-nums select-all sm:text-3xl"
                    >
                      {{ formatAmount(recipientGets(), toCurrencyObj().code) }}
                    </div>
                  </div>
    
                  <!-- To Currency Selector -->
                  <div class="w-36 shrink-0">
                    <ui-select [value]="toCurrency()" (valueChange)="toCurrency.set($event)">
                      <button ui-select-trigger class="bg-muted/40 h-11 w-full text-xs font-medium sm:text-sm">
                        <ui-select-value placeholder="Select currency" />
                      </button>
                      <ui-select-content class="max-h-64">
                        @for (curr of currencies; track curr.code) {
                          <ui-select-item [value]="curr.code">
                            <div class="flex items-center gap-2">
                              <span class="text-base">{{ curr.flag }}</span>
                              <span class="font-semibold">{{ curr.code }}</span>
                              <span class="text-muted-foreground truncate text-xs">({{ curr.name }})</span>
                            </div>
                          </ui-select-item>
                        }
                      </ui-select-content>
                    </ui-select>
                  </div>
                </div>
              </div>
    
              <!-- Delivery Speed & Savings Breakdown Banner -->
              <div class="border-border bg-muted/30 space-y-3 rounded-lg border p-4">
                <div class="flex flex-wrap items-center justify-between gap-2">
                  <div class="flex items-center gap-2 text-xs">
                    <span class="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full">
                      <lucide-icon [img]="Zap" class="size-3.5" />
                    </span>
                    <div>
                      <p class="text-foreground font-semibold">Estimated delivery: {{ toCurrencyObj().deliverySpeed }}</p>
                      <p class="text-muted-foreground">Via {{ toCurrencyObj().deliveryNetwork }}</p>
                    </div>
                  </div>
    
                  <span ui-badge variant="outline" class="border-warning/30 bg-warning/10 text-warning gap-1 py-1 text-xs">
                    <lucide-icon [img]="Coins" class="size-3" />
                    Save ~{{ fromCurrencyObj().symbol }}{{ formatAmount(estimatedSavings(), fromCurrencyObj().code) }}
                    {{ fromCurrencyObj().code }}
                  </span>
                </div>
    
                <ui-separator />
    
                <!-- Comparison vs Standard High-Street Bank -->
                <div class="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
                  <div class="bg-card/60 border-border flex items-start gap-2 rounded-md border p-2.5">
                    <lucide-icon [img]="ShieldCheck" class="text-success mt-0.5 size-4 shrink-0" />
                    <div>
                      <p class="text-foreground font-medium">UIPKGE Transfer</p>
                      <p class="text-muted-foreground text-xs">
                        Fee: {{ fromCurrencyObj().symbol }}{{ formatAmount(totalFee(), fromCurrencyObj().code) }} · 0%
                        exchange spread
                      </p>
                    </div>
                  </div>
    
                  <div class="bg-card/60 border-border flex items-start gap-2 rounded-md border p-2.5 opacity-75">
                    <lucide-icon [img]="Building2" class="text-muted-foreground mt-0.5 size-4 shrink-0" />
                    <div>
                      <p class="text-foreground font-medium">Standard Bank Wire</p>
                      <p class="text-muted-foreground text-xs">
                        Fee: ~{{ fromCurrencyObj().symbol }}{{ formatAmount(bankTotalCost(), fromCurrencyObj().code) }} ·
                        ~3.2% hidden spread
                      </p>
                    </div>
                  </div>
                </div>
              </div>
    
              <!-- Primary Action Button -->
              <button ui-button class="h-11 w-full gap-2 text-base font-semibold shadow-sm" (click)="handleTransfer()">
                <span
                  >Get Started / Send {{ fromCurrencyObj().symbol
                  }}{{ formatAmount(sendAmount(), fromCurrencyObj().code) }}</span
                >
                <lucide-icon [img]="ArrowRight" class="size-4" />
              </button>
    
              <p class="text-muted-foreground flex items-center justify-center gap-1.5 text-center text-xs">
                <lucide-icon [img]="Lock" class="text-muted-foreground size-3" />
                <span>Rates locked for 24 hours. Licensed & regulated financial institution.</span>
              </p>
            </div>
          </div>
    
          <!-- Popular Currency Pairs Quick Rate Ticker -->
          <div class="space-y-3">
            <div class="flex items-center justify-between">
              <h3 class="text-foreground text-sm font-semibold">Popular Currency Pairs</h3>
              <span class="text-muted-foreground text-xs">Click pair to load</span>
            </div>
    
            <div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
              @for (pair of popularPairs; track pair.id) {
                <button type="button" [class]="pairCardClass(pair)" (click)="handleSelectPair(pair)">
                  <div class="flex w-full items-center justify-between text-xs">
                    <span class="text-foreground font-semibold">{{ pair.from }} / {{ pair.to }}</span>
                    <span [class]="pairTrendClass(pair)">
                      @if (pair.trend === 'up') {
                        <lucide-icon [img]="ArrowUpRight" class="size-3" />
                      } @else {
                        <lucide-icon [img]="ArrowDownRight" class="size-3" />
                      }
                      {{ pair.change24h > 0 ? '+' + pair.change24h + '%' : pair.change24h + '%' }}
                    </span>
                  </div>
    
                  <div class="text-foreground text-sm font-bold tabular-nums">
                    {{ getPairRate(pair.from, pair.to) }}
                  </div>
                </button>
              }
            </div>
          </div>
        </div>
      `,
    })
    export class UiCurrencyConverterWidgetComponent {
      protected readonly ArrowDownRight = ArrowDownRight
      protected readonly ArrowRight = ArrowRight
      protected readonly ArrowUpDown = ArrowUpDown
      protected readonly ArrowUpRight = ArrowUpRight
      protected readonly Building2 = Building2
      protected readonly Coins = Coins
      protected readonly Lock = Lock
      protected readonly Minus = Minus
      protected readonly RefreshCw = RefreshCw
      protected readonly ShieldCheck = ShieldCheck
      protected readonly Zap = Zap
    
      protected readonly currencies = CURRENCIES
      protected readonly popularPairs = POPULAR_PAIRS
    
      @Input() set initialSendAmount(v: number | undefined) {
        if (v !== undefined) this.sendAmount.set(v)
      }
      @Input() set initialFromCurrency(v: string | undefined) {
        if (v) this.fromCurrency.set(v)
      }
      @Input() set initialToCurrency(v: string | undefined) {
        if (v) this.toCurrency.set(v)
      }
      @Input('class') className?: string
      @Output() transfer = new EventEmitter<TransferPayload>()
    
      readonly sendAmount = signal(1000)
      readonly fromCurrency = signal('USD')
      readonly toCurrency = signal('EUR')
      readonly paymentMethod = signal<'bank' | 'card'>('bank')
      readonly isRefreshing = signal(false)
      readonly refreshSuccess = signal(false)
    
      readonly fromCurrencyObj = computed(() => CURRENCIES.find((c) => c.code === this.fromCurrency()) || CURRENCIES[0]!)
      readonly toCurrencyObj = computed(() => CURRENCIES.find((c) => c.code === this.toCurrency()) || CURRENCIES[1]!)
    
      // Dynamic mid-market rate calculation
      readonly exchangeRate = computed(() => {
        const from = this.fromCurrencyObj()
        const to = this.toCurrencyObj()
        if (!from || !to) return 1
        return to.rateToBase / from.rateToBase
      })
    
      readonly inverseExchangeRate = computed(() => {
        if (this.exchangeRate() === 0) return 0
        return 1 / this.exchangeRate()
      })
    
      readonly formattedExchangeRate = computed(() => {
        const r = this.exchangeRate()
        if (r >= 100) return r.toFixed(2)
        if (r >= 1) return r.toFixed(4)
        return r.toFixed(5)
      })
    
      // Fee breakdown logic
      readonly fixedBaseFee = computed(() => {
        // Base fixed fee equivalent to ~$1.20 USD
        const fromRate = this.fromCurrencyObj().rateToBase
        const base = 1.2 * fromRate
        return this.paymentMethod() === 'card' ? base * 1.6 : base
      })
    
      readonly variableRate = computed(() => (this.paymentMethod() === 'card' ? 0.0065 : 0.0035))
    
      readonly variableFee = computed(() => {
        const amt = Number(this.sendAmount()) || 0
        return amt * this.variableRate()
      })
    
      readonly totalFee = computed(() => this.fixedBaseFee() + this.variableFee())
    
      readonly amountToConvert = computed(() => {
        const amt = Number(this.sendAmount()) || 0
        return Math.max(0, amt - this.totalFee())
      })
    
      readonly recipientGets = computed(() => this.amountToConvert() * this.exchangeRate())
    
      // Bank comparison calculation
      readonly bankWireFee = computed(() => 20 * this.fromCurrencyObj().rateToBase)
      private readonly bankMarkupSpread = 0.032 // 3.2% average hidden bank margin
      readonly bankTotalCost = computed(() => {
        const amt = Number(this.sendAmount()) || 0
        return this.bankWireFee() + amt * this.bankMarkupSpread
      })
    
      readonly estimatedSavings = computed(() => Math.max(0, this.bankTotalCost() - this.totalFee()))
    
      get rootClass(): string {
        return cn('mx-auto w-full max-w-2xl space-y-6', this.className)
      }
    
      get refreshIconClass(): string {
        return cn('size-3.5 transition-transform duration-500', this.isRefreshing() ? 'text-primary animate-spin' : '')
      }
    
      methodTabClass(method: 'bank' | 'card'): string {
        return cn(
          'min-h-6 rounded-md px-3 py-1 font-medium transition-colors',
          this.paymentMethod() === method
            ? 'bg-card text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      pairCardClass(pair: PopularPair): string {
        const selected = this.fromCurrency() === pair.from && this.toCurrency() === pair.to
        return cn(
          'hover:bg-accent focus-visible:ring-ring flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none',
          selected ? 'border-primary bg-primary/5 ring-primary/20 ring-1' : 'border-border bg-card',
        )
      }
    
      pairTrendClass(pair: PopularPair): string {
        return cn('flex items-center text-xs font-medium', pair.trend === 'up' ? 'text-success' : 'text-destructive')
      }
    
      formatAmount(val: number, currencyCode: string): string {
        return formatAmount(val, currencyCode)
      }
    
      getPairRate(fromCode: string, toCode: string): string {
        return getPairRate(fromCode, toCode)
      }
    
      onAmountChange(raw: string): void {
        this.sendAmount.set(Number.parseFloat(raw) || 0)
      }
    
      handleSwap(): void {
        const temp = this.fromCurrency()
        this.fromCurrency.set(this.toCurrency())
        this.toCurrency.set(temp)
      }
    
      handleSelectPair(pair: PopularPair): void {
        this.fromCurrency.set(pair.from)
        this.toCurrency.set(pair.to)
      }
    
      handleRefresh(): void {
        if (this.isRefreshing()) return
        this.isRefreshing.set(true)
        setTimeout(() => {
          this.isRefreshing.set(false)
          this.refreshSuccess.set(true)
          setTimeout(() => {
            this.refreshSuccess.set(false)
          }, 2000)
        }, 450)
      }
    
      handleTransfer(): void {
        this.transfer.emit({
          sendAmount: Number(this.sendAmount()) || 0,
          fromCurrency: this.fromCurrency(),
          toCurrency: this.toCurrency(),
          recipientGets: this.recipientGets(),
          fee: this.totalFee(),
          rate: this.exchangeRate(),
        })
      }
    }
    
  • src/app/components/blocks/currency-converter-widget/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/currency-converter-widget.json