
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/currency-converter-widget.json$npx shadcn@latest add https://uipkge.dev/r/react/currency-converter-widget.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/currency-converter-widget.json$bunx shadcn@latest add https://uipkge.dev/r/react/currency-converter-widget.jsonnpx shadcn@latest add @uipkge-react/currency-converter-widgetInstalls to:components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
initialSendAmount | number | — | optional |
initialFromCurrency | string | — | optional |
initialToCurrency | string | — | optional |
className | string | — | optional |
onTransfer | (payload: TransferPayload) => void | — | optional |
Type aliases exported from this item's source. Use these to shape the data you pass in.
CurrencyOptioninterface CurrencyOption {
code: string
name: string
symbol: string
flag: string
rateToBase: number // USD base = 1.0
deliverySpeed: string
deliveryNetwork: string
}PopularPairinterface PopularPair {
id: string
from: string
to: string
change24h: number
trend: 'up' | 'down'
}TransferPayloadinterface TransferPayload {
sendAmount: number
fromCurrency: string
toCurrency: string
recipientGets: number
fee: number
rate: number
}'use client'
import * as React from 'react'
import {
ArrowDownRight,
ArrowRight,
ArrowUpDown,
ArrowUpRight,
Building2,
Lock,
Minus,
RefreshCw,
ShieldCheck,
Coins,
Zap,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
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
}
export interface CurrencyConverterWidgetProps {
initialSendAmount?: number
initialFromCurrency?: string
initialToCurrency?: string
className?: string
onTransfer?: (payload: TransferPayload) => void
}
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)
}
export function CurrencyConverterWidget({
initialSendAmount = 1000,
initialFromCurrency = 'USD',
initialToCurrency = 'EUR',
className,
onTransfer,
}: CurrencyConverterWidgetProps) {
const [sendAmount, setSendAmount] = React.useState<number>(initialSendAmount)
const [fromCurrency, setFromCurrency] = React.useState<string>(initialFromCurrency)
const [toCurrency, setToCurrency] = React.useState<string>(initialToCurrency)
const [paymentMethod, setPaymentMethod] = React.useState<'bank' | 'card'>('bank')
const [isRefreshing, setIsRefreshing] = React.useState<boolean>(false)
const [refreshSuccess, setRefreshSuccess] = React.useState<boolean>(false)
const fromCurrencyObj = React.useMemo(
() => CURRENCIES.find((c) => c.code === fromCurrency) || CURRENCIES[0],
[fromCurrency],
)
const toCurrencyObj = React.useMemo(
() => CURRENCIES.find((c) => c.code === toCurrency) || CURRENCIES[1],
[toCurrency],
)
// Dynamic mid-market rate calculation
const exchangeRate = React.useMemo(() => {
if (!fromCurrencyObj || !toCurrencyObj) return 1
return toCurrencyObj.rateToBase / fromCurrencyObj.rateToBase
}, [fromCurrencyObj, toCurrencyObj])
const formattedExchangeRate = React.useMemo(() => {
if (exchangeRate >= 100) return exchangeRate.toFixed(2)
if (exchangeRate >= 1) return exchangeRate.toFixed(4)
return exchangeRate.toFixed(5)
}, [exchangeRate])
// Fee calculation logic
const fixedBaseFee = React.useMemo(() => {
const base = 1.2 * fromCurrencyObj.rateToBase
return paymentMethod === 'card' ? base * 1.6 : base
}, [fromCurrencyObj, paymentMethod])
const variableRate = paymentMethod === 'card' ? 0.0065 : 0.0035
const variableFee = (sendAmount || 0) * variableRate
const totalFee = fixedBaseFee + variableFee
const amountToConvert = Math.max(0, (sendAmount || 0) - totalFee)
const recipientGets = amountToConvert * exchangeRate
// Bank comparison calculation
const bankWireFee = 20 * fromCurrencyObj.rateToBase
const bankMarkupSpread = 0.032 // 3.2% average hidden bank margin
const bankTotalCost = bankWireFee + (sendAmount || 0) * bankMarkupSpread
const estimatedSavings = Math.max(0, bankTotalCost - totalFee)
const handleSwap = React.useCallback(() => {
setFromCurrency(toCurrency)
setToCurrency(fromCurrency)
}, [fromCurrency, toCurrency])
const handleSelectPair = React.useCallback((pair: PopularPair) => {
setFromCurrency(pair.from)
setToCurrency(pair.to)
}, [])
const handleRefresh = React.useCallback(() => {
if (isRefreshing) return
setIsRefreshing(true)
setTimeout(() => {
setIsRefreshing(false)
setRefreshSuccess(true)
setTimeout(() => {
setRefreshSuccess(false)
}, 2000)
}, 450)
}, [isRefreshing])
const handleTransfer = React.useCallback(() => {
onTransfer?.({
sendAmount: Number(sendAmount) || 0,
fromCurrency,
toCurrency,
recipientGets,
fee: totalFee,
rate: exchangeRate,
})
}, [sendAmount, fromCurrency, toCurrency, recipientGets, totalFee, exchangeRate, onTransfer])
return (
<div className={cn('mx-auto w-full max-w-2xl space-y-6', className)} data-slot="currency-converter-widget">
{/* Header Section */}
<div className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-foreground text-xl font-bold tracking-tight sm:text-2xl">
Currency Converter & International Transfer
</h2>
<Badge variant="outline" className="gap-1.5 py-1 text-xs font-normal">
<span className="bg-success size-2 animate-pulse rounded-full" />
Live Market Rates
</Badge>
</div>
<p className="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 className="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 className="flex items-center gap-2">
<span className="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"
className="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"
onClick={handleRefresh}
>
<RefreshCw
className={cn(
'size-3.5 transition-transform duration-500',
isRefreshing ? 'text-primary animate-spin' : '',
)}
/>
<span>{refreshSuccess ? 'Refreshed!' : 'Refresh'}</span>
</button>
</div>
</div>
{/* Main Converter Card */}
<Card className="border-border shadow-xs">
<CardContent className="space-y-4 p-5 sm:p-6">
{/* Payment method tab pill */}
<div className="flex items-center justify-between gap-2">
<div className="border-border bg-muted/50 inline-flex rounded-lg border p-1 text-xs">
<button
type="button"
className={cn(
'min-h-6 rounded-md px-3 py-1 font-medium transition-colors',
paymentMethod === 'bank'
? 'bg-card text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setPaymentMethod('bank')}
>
Bank Transfer (Lowest Fee)
</button>
<button
type="button"
className={cn(
'min-h-6 rounded-md px-3 py-1 font-medium transition-colors',
paymentMethod === 'card'
? 'bg-card text-foreground shadow-xs'
: 'text-muted-foreground hover:text-foreground',
)}
onClick={() => setPaymentMethod('card')}
>
Debit / Credit Card
</button>
</div>
<span className="text-muted-foreground hidden text-xs sm:inline-flex">Guaranteed rate for 24h</span>
</div>
{/* "You send" Input Container */}
<div className="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 className="text-muted-foreground mb-1.5 flex items-center justify-between text-xs">
<label htmlFor="send-amount-react-input" className="text-foreground font-medium">
You send
</label>
<span>Max limit: $100,000 / transfer</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-1 items-baseline gap-1">
<span className="text-muted-foreground text-xl font-semibold select-none sm:text-2xl">
{fromCurrencyObj.symbol}
</span>
<input
id="send-amount-react-input"
value={Number.isNaN(sendAmount) ? '' : sendAmount}
onChange={(e) => setSendAmount(Number.parseFloat(e.target.value) || 0)}
type="number"
min="1"
step="any"
placeholder="1,000.00"
className="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 className="w-36 shrink-0">
<Select value={fromCurrency} onValueChange={(val) => setFromCurrency(val)}>
<SelectTrigger className="bg-muted/40 h-11 w-full text-xs font-medium sm:text-sm">
<div className="flex items-center gap-2 text-left">
<span className="text-base leading-none">{fromCurrencyObj.flag}</span>
<span className="font-semibold">{fromCurrencyObj.code}</span>
</div>
</SelectTrigger>
<SelectContent className="max-h-64">
{CURRENCIES.map((curr) => (
<SelectItem key={curr.code} value={curr.code}>
<div className="flex items-center gap-2">
<span className="text-base">{curr.flag}</span>
<span className="font-semibold">{curr.code}</span>
<span className="text-muted-foreground truncate text-xs">({curr.name})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Transfer Breakdown Rail (Vertical Connector) */}
<div className="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 className="relative flex items-center justify-between text-xs">
<div className="border-border bg-card text-muted-foreground absolute -left-[19px] flex size-3.5 items-center justify-center rounded-full border">
<Minus className="size-2.5" />
</div>
<div className="flex items-center gap-1.5">
<span className="text-foreground font-semibold tabular-nums">
-{fromCurrencyObj.symbol}
{formatAmount(totalFee, fromCurrencyObj.code)} {fromCurrencyObj.code}
</span>
<span className="text-muted-foreground">Transfer fee</span>
<Badge variant="secondary" className="text-muted-foreground h-4.5 px-1.5 text-xs font-normal">
{paymentMethod === 'card' ? '0.65% + $1.20' : '0.35% + $1.20'}
</Badge>
</div>
<span className="text-muted-foreground hidden sm:inline">Transparent fixed rate</span>
</div>
{/* Step 2: Amount to convert */}
<div className="relative flex items-center justify-between text-xs">
<div className="border-border bg-card text-muted-foreground absolute -left-[19px] flex size-3.5 items-center justify-center rounded-full border">
<span className="text-xs leading-none font-bold">=</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-foreground font-semibold tabular-nums">
{fromCurrencyObj.symbol}
{formatAmount(amountToConvert, fromCurrencyObj.code)} {fromCurrencyObj.code}
</span>
<span className="text-muted-foreground">Amount to convert</span>
</div>
<span className="text-muted-foreground hidden sm:inline">Net transfer base</span>
</div>
{/* Step 3: Guaranteed Exchange Rate & Swap Button Line */}
<div className="relative flex flex-wrap items-center justify-between gap-2 text-xs">
<div className="border-primary/50 bg-primary text-primary-foreground absolute -left-[19px] flex size-3.5 items-center justify-center rounded-full border">
<Lock className="size-2" />
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-foreground font-semibold tabular-nums">× {formattedExchangeRate}</span>
<span className="text-muted-foreground">Guaranteed mid-market rate</span>
<Badge variant="outline" className="border-success/30 bg-success/10 text-success gap-1 py-0.5 text-xs">
<Lock className="size-2.5" />
24h Guarantee
</Badge>
</div>
{/* Swap Currencies Button */}
<button
type="button"
aria-label="Swap currencies"
className="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"
onClick={handleSwap}
>
<ArrowUpDown className="text-primary size-3" />
<span>Swap</span>
</button>
</div>
</div>
{/* "Recipient gets" Input / Output Container */}
<div className="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 className="text-muted-foreground mb-1.5 flex items-center justify-between text-xs">
<label className="text-foreground font-medium">Recipient gets</label>
<span className="text-success flex items-center gap-1 font-medium">
<Zap className="size-3" />
{toCurrencyObj.deliverySpeed}
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex min-w-0 flex-1 items-baseline gap-1">
<span className="text-muted-foreground text-xl font-semibold select-none sm:text-2xl">
{toCurrencyObj.symbol}
</span>
<div className="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 className="w-36 shrink-0">
<Select value={toCurrency} onValueChange={(val) => setToCurrency(val)}>
<SelectTrigger className="bg-muted/40 h-11 w-full text-xs font-medium sm:text-sm">
<div className="flex items-center gap-2 text-left">
<span className="text-base leading-none">{toCurrencyObj.flag}</span>
<span className="font-semibold">{toCurrencyObj.code}</span>
</div>
</SelectTrigger>
<SelectContent className="max-h-64">
{CURRENCIES.map((curr) => (
<SelectItem key={curr.code} value={curr.code}>
<div className="flex items-center gap-2">
<span className="text-base">{curr.flag}</span>
<span className="font-semibold">{curr.code}</span>
<span className="text-muted-foreground truncate text-xs">({curr.name})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Delivery Speed & Savings Breakdown Banner */}
<div className="border-border bg-muted/30 space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs">
<span className="bg-primary/10 text-primary flex size-6 items-center justify-center rounded-full">
<Zap className="size-3.5" />
</span>
<div>
<p className="text-foreground font-semibold">Estimated delivery: {toCurrencyObj.deliverySpeed}</p>
<p className="text-muted-foreground">Via {toCurrencyObj.deliveryNetwork}</p>
</div>
</div>
<Badge variant="outline" className="border-warning/30 bg-warning/10 text-warning gap-1 py-1 text-xs">
<Coins className="size-3" />
Save ~{fromCurrencyObj.symbol}
{formatAmount(estimatedSavings, fromCurrencyObj.code)} {fromCurrencyObj.code}
</Badge>
</div>
<Separator />
{/* Comparison vs Standard High-Street Bank */}
<div className="grid grid-cols-1 gap-3 text-xs sm:grid-cols-2">
<div className="bg-card/60 border-border flex items-start gap-2 rounded-md border p-2.5">
<ShieldCheck className="text-success mt-0.5 size-4 shrink-0" />
<div>
<p className="text-foreground font-medium">UIPKGE Transfer</p>
<p className="text-muted-foreground text-xs">
Fee: {fromCurrencyObj.symbol}
{formatAmount(totalFee, fromCurrencyObj.code)} · 0% exchange spread
</p>
</div>
</div>
<div className="bg-card/60 border-border flex items-start gap-2 rounded-md border p-2.5 opacity-75">
<Building2 className="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<p className="text-foreground font-medium">Standard Bank Wire</p>
<p className="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 className="h-11 w-full gap-2 text-base font-semibold shadow-sm" onClick={handleTransfer}>
<span>
Get Started / Send {fromCurrencyObj.symbol}
{formatAmount(sendAmount || 0, fromCurrencyObj.code)}
</span>
<ArrowRight className="size-4" />
</Button>
<p className="text-muted-foreground flex items-center justify-center gap-1.5 text-center text-xs">
<Lock className="text-muted-foreground size-3" />
<span>Rates locked for 24 hours. Licensed & regulated financial institution.</span>
</p>
</CardContent>
</Card>
{/* Popular Currency Pairs Quick Rate Ticker */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-foreground text-sm font-semibold">Popular Currency Pairs</h3>
<span className="text-muted-foreground text-xs">Click pair to load</span>
</div>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{POPULAR_PAIRS.map((pair) => {
const isSelected = fromCurrency === pair.from && toCurrency === pair.to
return (
<button
key={pair.id}
type="button"
className={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',
isSelected ? 'border-primary bg-primary/5 ring-primary/20 ring-1' : 'border-border bg-card',
)}
onClick={() => handleSelectPair(pair)}
>
<div className="flex w-full items-center justify-between text-xs">
<span className="text-foreground font-semibold">
{pair.from} / {pair.to}
</span>
<span
className={cn(
'flex items-center text-xs font-medium',
pair.trend === 'up' ? 'text-success' : 'text-destructive',
)}
>
{pair.trend === 'up' ? <ArrowUpRight className="size-3" /> : <ArrowDownRight className="size-3" />}
{pair.change24h > 0 ? `+${pair.change24h}%` : `${pair.change24h}%`}
</span>
</div>
<div className="text-foreground text-sm font-bold tabular-nums">{getPairRate(pair.from, pair.to)}</div>
</button>
)
})}
</div>
</div>
</div>
)
}
Raw manifest:https://uipkge.dev/r/react/currency-converter-widget.json