Files
nebula/components/transactions.templ

501 lines
18 KiB
Plaintext

package components
type TxType string
const (
TxTypeSend TxType = "send"
TxTypeReceive TxType = "receive"
TxTypeSwap TxType = "swap"
TxTypeApprove TxType = "approve"
TxTypeContract TxType = "contract"
)
type TxStatus string
const (
TxStatusConfirmed TxStatus = "confirmed"
TxStatusPending TxStatus = "pending"
TxStatusFailed TxStatus = "failed"
)
type TransactionDetail struct {
Type TxType
Title string
Description string
Asset string
AssetColor string
ToAsset string
ToAssetColor string
Amount string
AmountUSD string
IsPositive bool
Time string
Date string
Status TxStatus
Hash string
ExplorerURL string
}
type TxStats struct {
TotalReceived string
ReceivedCount int
TotalSent string
SentCount int
SwapCount int
SwapVolume string
GasSpent string
}
type TxFilter struct {
Types []string
Assets []string
Accounts []string
}
type TxDateGroup struct {
Date string
Count int
Transactions []TransactionDetail
}
func DefaultTxStats() TxStats {
return TxStats{
TotalReceived: "15420.50",
ReceivedCount: 42,
TotalSent: "8234.18",
SentCount: 28,
SwapCount: 12,
SwapVolume: "$4,892.00",
GasSpent: "127.45",
}
}
func DefaultTxFilter() TxFilter {
return TxFilter{
Types: []string{"send", "receive", "swap", "approve", "contract"},
Assets: []string{"ETH", "SNR", "USDC", "AVAX"},
Accounts: []string{"Main Wallet", "Trading", "Savings"},
}
}
func DefaultTxGroups() []TxDateGroup {
return []TxDateGroup{
{
Date: "Today",
Count: 3,
Transactions: []TransactionDetail{
{Type: TxTypeReceive, Title: "Received ETH", Description: "From: 0x742d...35Cb", Asset: "ETH", AssetColor: "#627eea", Amount: "+0.25 ETH", AmountUSD: "$586.25", IsPositive: true, Time: "2:34 PM", Status: TxStatusConfirmed, Hash: "0x8f4a2b1c..."},
{Type: TxTypeSwap, Title: "Swapped SNR → USDC", Description: "via Uniswap V3", Asset: "SNR", AssetColor: "linear-gradient(135deg, #17c2ff, #0090ff)", ToAsset: "USDC", ToAssetColor: "#2775ca", Amount: "500 SNR → 248.50 USDC", AmountUSD: "$248.50", IsPositive: false, Time: "11:22 AM", Status: TxStatusConfirmed, Hash: "0x7d3e2a1b..."},
{Type: TxTypeApprove, Title: "Approved USDC", Description: "Spender: Uniswap V3 Router", Asset: "USDC", AssetColor: "#2775ca", Amount: "Unlimited", AmountUSD: "—", IsPositive: false, Time: "11:20 AM", Status: TxStatusConfirmed, Hash: "0x1a2b3c4d..."},
},
},
{
Date: "Yesterday",
Count: 2,
Transactions: []TransactionDetail{
{Type: TxTypeSend, Title: "Sent SNR", Description: "To: sonr1k4m...9p3q", Asset: "SNR", AssetColor: "linear-gradient(135deg, #17c2ff, #0090ff)", Amount: "-500 SNR", AmountUSD: "$250.00", IsPositive: false, Time: "4:18 PM", Status: TxStatusConfirmed, Hash: "0x9f8e7d6c..."},
{Type: TxTypeReceive, Title: "Received AVAX", Description: "From: 0xaBcD...1234", Asset: "AVAX", AssetColor: "#e84142", Amount: "+10.00 AVAX", AmountUSD: "$281.50", IsPositive: true, Time: "9:45 AM", Status: TxStatusConfirmed, Hash: "0x2b3c4d5e..."},
},
},
{
Date: "January 1, 2026",
Count: 4,
Transactions: []TransactionDetail{
{Type: TxTypeSwap, Title: "Swapped ETH → USDC", Description: "via Uniswap V3", Asset: "ETH", AssetColor: "#627eea", ToAsset: "USDC", ToAssetColor: "#2775ca", Amount: "0.5 ETH → 1,172.50 USDC", AmountUSD: "$1,172.50", IsPositive: false, Time: "9:15 AM", Status: TxStatusConfirmed, Hash: "0x3c4d5e6f..."},
{Type: TxTypeContract, Title: "Contract Interaction", Description: "Staking Contract: 0x5f3c...8a2b", Asset: "SNR", AssetColor: "linear-gradient(135deg, #17c2ff, #0090ff)", Amount: "-1,000 SNR", AmountUSD: "$500.00 staked", IsPositive: false, Time: "8:30 AM", Status: TxStatusConfirmed, Hash: "0x4d5e6f7a..."},
{Type: TxTypeSend, Title: "Sent ETH", Description: "To: 0x9f8e...7d6c", Asset: "ETH", AssetColor: "#627eea", Amount: "-0.15 ETH", AmountUSD: "$351.85", IsPositive: false, Time: "7:22 AM", Status: TxStatusConfirmed, Hash: "0x5e6f7a8b..."},
{Type: TxTypeReceive, Title: "Received SNR", Description: "From: sonr1abc...xyz9 (Airdrop)", Asset: "SNR", AssetColor: "linear-gradient(135deg, #17c2ff, #0090ff)", Amount: "+2,500 SNR", AmountUSD: "$1,250.00", IsPositive: true, Time: "12:00 AM", Status: TxStatusConfirmed, Hash: "0x6f7a8b9c..."},
},
},
}
}
func assetInitial(asset string) string {
if len(asset) > 0 {
return string(asset[0])
}
return "?"
}
templ TxIcon(txType TxType) {
<div class={ "tx-icon", string(txType) }>
switch txType {
case TxTypeSend:
<wa-icon name="arrow-up"></wa-icon>
case TxTypeReceive:
<wa-icon name="arrow-down"></wa-icon>
case TxTypeSwap:
<wa-icon name="arrow-right-arrow-left"></wa-icon>
case TxTypeApprove:
<wa-icon name="check"></wa-icon>
case TxTypeContract:
<wa-icon name="file-code"></wa-icon>
}
</div>
}
templ TxAssetBadge(asset string, color string, toAsset string, toColor string) {
<div class="tx-asset">
<wa-avatar initials={ assetInitial(asset) } style={ "--size: 24px; background: " + color + ";" }></wa-avatar>
if toAsset != "" {
<wa-icon name="arrow-right" style="font-size: 10px; color: var(--wa-color-neutral-400);"></wa-icon>
<wa-avatar initials={ assetInitial(toAsset) } style={ "--size: 24px; background: " + toColor + ";" }></wa-avatar>
} else {
<span>{ asset }</span>
}
</div>
}
templ TxAmount(amount string, usd string, isPositive bool) {
<div class={ "tx-amount", templ.KV("positive", isPositive), templ.KV("negative", !isPositive) }>
<div class="value">{ amount }</div>
<div class="usd">{ usd }</div>
</div>
}
templ TxTime(time string, status TxStatus) {
<div class="tx-time">
<div class="time">{ time }</div>
switch status {
case TxStatusConfirmed:
<wa-badge variant="success" size="small">Confirmed</wa-badge>
case TxStatusPending:
<wa-badge variant="warning" size="small">Pending</wa-badge>
case TxStatusFailed:
<wa-badge variant="danger" size="small">Failed</wa-badge>
}
</div>
}
templ TxActions(hash string) {
<div class="tx-actions">
<wa-tooltip content="View on Explorer">
<wa-icon-button name="arrow-up-right-from-square" label="Explorer"></wa-icon-button>
</wa-tooltip>
<wa-copy-button value={ hash } copy-label="Copy hash"></wa-copy-button>
</div>
}
templ TxRow(tx TransactionDetail) {
<div class="tx-row">
@TxIcon(tx.Type)
<div class="tx-details">
<div class="tx-type-label">{ tx.Title }</div>
<div class="tx-address">{ tx.Description }</div>
</div>
@TxAssetBadge(tx.Asset, tx.AssetColor, tx.ToAsset, tx.ToAssetColor)
@TxAmount(tx.Amount, tx.AmountUSD, tx.IsPositive)
@TxTime(tx.Time, tx.Status)
@TxActions(tx.Hash)
</div>
}
templ TxDateGroupComponent(group TxDateGroup) {
<div class="date-group">
<div class="date-group-header">
<h3>{ group.Date }</h3>
<span class="tx-count">{ formatTxCount(group.Count) }</span>
</div>
for _, tx := range group.Transactions {
@TxRow(tx)
}
</div>
}
func formatTxCount(count int) string {
if count == 1 {
return "1 transaction"
}
return string(rune('0'+count/10)) + string(rune('0'+count%10)) + " transactions"
}
templ TxStatsGrid(stats TxStats) {
<div class="wa-grid stats-grid">
<wa-card class="stat-card">
<div class="wa-stack wa-gap-3xs">
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Total Received</span>
<span class="wa-heading-l change-positive">
+<wa-format-number type="currency" currency="USD" value={ stats.TotalReceived } lang="en-US"></wa-format-number>
</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ formatTxCount(stats.ReceivedCount) }</span>
</div>
</wa-card>
<wa-card class="stat-card">
<div class="wa-stack wa-gap-3xs">
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Total Sent</span>
<span class="wa-heading-l change-negative">
-<wa-format-number type="currency" currency="USD" value={ stats.TotalSent } lang="en-US"></wa-format-number>
</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ formatTxCount(stats.SentCount) }</span>
</div>
</wa-card>
<wa-card class="stat-card">
<div class="wa-stack wa-gap-3xs">
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Swaps</span>
<span class="wa-heading-l">{ formatSwapCount(stats.SwapCount) }</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">{ stats.SwapVolume } volume</span>
</div>
</wa-card>
<wa-card class="stat-card">
<div class="wa-stack wa-gap-3xs">
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">Gas Spent</span>
<span class="wa-heading-l">
<wa-format-number type="currency" currency="USD" value={ stats.GasSpent } lang="en-US"></wa-format-number>
</span>
<span class="wa-caption-xs" style="color: var(--wa-color-neutral-500);">All time</span>
</div>
</wa-card>
</div>
}
func formatSwapCount(count int) string {
if count < 10 {
return string(rune('0' + count))
}
return string(rune('0'+count/10)) + string(rune('0'+count%10))
}
templ TxFilterBar(filter TxFilter) {
<div class="filter-bar">
<wa-select placeholder="All Types" size="small" style="min-width: 140px;" clearable name="filter-type">
<wa-option value="send">
<wa-icon slot="prefix" name="arrow-up" style="color: var(--wa-color-danger);"></wa-icon>
Send
</wa-option>
<wa-option value="receive">
<wa-icon slot="prefix" name="arrow-down" style="color: var(--wa-color-success);"></wa-icon>
Receive
</wa-option>
<wa-option value="swap">
<wa-icon slot="prefix" name="arrow-right-arrow-left" style="color: var(--wa-color-primary);"></wa-icon>
Swap
</wa-option>
<wa-option value="approve">
<wa-icon slot="prefix" name="check"></wa-icon>
Approve
</wa-option>
<wa-option value="contract">
<wa-icon slot="prefix" name="file-code"></wa-icon>
Contract
</wa-option>
</wa-select>
<wa-select placeholder="All Assets" size="small" style="min-width: 140px;" clearable name="filter-asset">
for _, asset := range filter.Assets {
<wa-option value={ asset }>{ asset }</wa-option>
}
</wa-select>
<wa-select placeholder="All Accounts" size="small" style="min-width: 160px;" clearable name="filter-account">
for _, account := range filter.Accounts {
<wa-option value={ account }>{ account }</wa-option>
}
</wa-select>
<wa-input type="date" size="small" style="width: 150px;" placeholder="From date" name="filter-from"></wa-input>
<span style="color: var(--wa-color-neutral-400);">—</span>
<wa-input type="date" size="small" style="width: 150px;" placeholder="To date" name="filter-to"></wa-input>
<div style="flex: 1;"></div>
<wa-input placeholder="Search by hash or address..." size="small" style="width: 240px;" name="filter-search">
<wa-icon slot="prefix" name="magnifying-glass"></wa-icon>
</wa-input>
</div>
}
templ TxPagination(page int, total int, perPage int) {
<div class="pagination">
<div class="pagination-info">
Showing <strong>{ formatRange(page, perPage) }</strong> of <strong>{ formatTotal(total) }</strong> transactions
</div>
<div class="pagination-controls">
<wa-select value={ formatPerPage(perPage) } size="small" style="width: 100px;" name="limit">
<wa-option value="10">10 / page</wa-option>
<wa-option value="25">25 / page</wa-option>
<wa-option value="50">50 / page</wa-option>
<wa-option value="100">100 / page</wa-option>
</wa-select>
<div class="page-numbers">
<button class={ "page-btn", templ.KV("disabled", page == 1) } disabled?={ page == 1 }>
<wa-icon name="chevron-left"></wa-icon>
</button>
@pageButtons(page, totalPages(total, perPage))
<button class={ "page-btn", templ.KV("disabled", page >= totalPages(total, perPage)) } disabled?={ page >= totalPages(total, perPage) }>
<wa-icon name="chevron-right"></wa-icon>
</button>
</div>
</div>
</div>
}
func formatRange(page int, perPage int) string {
start := (page-1)*perPage + 1
end := page * perPage
return formatTotal(start) + "-" + formatTotal(end)
}
func formatTotal(n int) string {
if n < 10 {
return string(rune('0' + n))
}
if n < 100 {
return string(rune('0'+n/10)) + string(rune('0'+n%10))
}
return string(rune('0'+n/100)) + string(rune('0'+(n%100)/10)) + string(rune('0'+n%10))
}
func formatPerPage(n int) string {
return formatTotal(n)
}
func totalPages(total int, perPage int) int {
return (total + perPage - 1) / perPage
}
templ pageButtons(current int, total int) {
if total <= 5 {
for i := 1; i <= total; i++ {
<button class={ "page-btn", templ.KV("active", i == current) }>{ formatTotal(i) }</button>
}
} else {
<button class={ "page-btn", templ.KV("active", current == 1) }>1</button>
if current > 3 {
<span style="padding: 0 var(--wa-space-xs); color: var(--wa-color-neutral-400);">...</span>
}
if current > 2 && current < total-1 {
<button class="page-btn">{ formatTotal(current - 1) }</button>
<button class="page-btn active">{ formatTotal(current) }</button>
<button class="page-btn">{ formatTotal(current + 1) }</button>
} else if current <= 2 {
<button class={ "page-btn", templ.KV("active", current == 2) }>2</button>
<button class={ "page-btn", templ.KV("active", current == 3) }>3</button>
} else {
<button class={ "page-btn", templ.KV("active", current == total-2) }>{ formatTotal(total - 2) }</button>
<button class={ "page-btn", templ.KV("active", current == total-1) }>{ formatTotal(total - 1) }</button>
}
if current < total-2 {
<span style="padding: 0 var(--wa-space-xs); color: var(--wa-color-neutral-400);">...</span>
}
<button class={ "page-btn", templ.KV("active", current == total) }>{ formatTotal(total) }</button>
}
}
templ TxStyles() {
<style>
.tx-icon {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.tx-icon.send { background: var(--wa-color-danger-subtle); color: var(--wa-color-danger); }
.tx-icon.receive { background: var(--wa-color-success-subtle); color: var(--wa-color-success); }
.tx-icon.swap { background: var(--wa-color-primary-subtle); color: var(--wa-color-primary); }
.tx-icon.approve { background: var(--wa-color-neutral-100); color: var(--wa-color-neutral-600); }
.tx-icon.contract { background: var(--wa-color-warning-subtle); color: var(--wa-color-warning); }
.tx-asset {
display: flex;
align-items: center;
gap: var(--wa-space-xs);
}
.tx-row {
display: grid;
grid-template-columns: auto 1fr auto auto auto auto;
gap: var(--wa-space-m);
align-items: center;
padding: var(--wa-space-m);
background: var(--wa-color-surface);
border-radius: var(--wa-radius-m);
margin-bottom: var(--wa-space-s);
transition: box-shadow 0.15s;
}
.tx-row:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); }
.tx-details { min-width: 0; }
.tx-type-label { font-weight: 500; margin-bottom: 2px; }
.tx-address {
font-family: var(--wa-font-mono);
font-size: var(--wa-font-size-xs);
color: var(--wa-color-neutral-500);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx-amount { text-align: right; }
.tx-amount .value { font-weight: 500; }
.tx-amount .usd { font-size: var(--wa-font-size-xs); color: var(--wa-color-neutral-500); }
.tx-amount.positive .value { color: var(--wa-color-success); }
.tx-amount.negative .value { color: var(--wa-color-danger); }
.tx-time { text-align: right; min-width: 80px; }
.tx-time .time { font-size: var(--wa-font-size-s); margin-bottom: 2px; }
.tx-actions { display: flex; gap: var(--wa-space-2xs); }
.date-group { margin-bottom: var(--wa-space-xl); }
.date-group-header {
display: flex;
align-items: center;
gap: var(--wa-space-s);
padding: var(--wa-space-s) 0;
margin-bottom: var(--wa-space-s);
border-bottom: 1px solid var(--wa-color-neutral-200);
}
.date-group-header h3 {
margin: 0;
font-size: var(--wa-font-size-s);
font-weight: 600;
color: var(--wa-color-neutral-700);
}
.tx-count { font-size: var(--wa-font-size-xs); color: var(--wa-color-neutral-500); }
.filter-bar {
display: flex;
align-items: center;
gap: var(--wa-space-m);
margin-bottom: var(--wa-space-l);
flex-wrap: wrap;
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--wa-space-m) 0;
margin-top: var(--wa-space-l);
border-top: 1px solid var(--wa-color-neutral-200);
}
.pagination-info { font-size: var(--wa-font-size-s); color: var(--wa-color-neutral-500); }
.pagination-controls { display: flex; align-items: center; gap: var(--wa-space-xs); }
.page-numbers { display: flex; gap: var(--wa-space-2xs); }
.page-btn {
min-width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--wa-radius-s);
font-size: var(--wa-font-size-s);
cursor: pointer;
border: 1px solid var(--wa-color-neutral-200);
background: var(--wa-color-surface);
color: var(--wa-color-neutral-700);
transition: all 0.15s;
}
.page-btn:hover:not(:disabled) { background: var(--wa-color-surface-alt); }
.page-btn.active { background: var(--wa-color-primary); border-color: var(--wa-color-primary); color: white; }
.page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 1100px) {
.tx-row { grid-template-columns: auto 1fr auto auto auto; }
.tx-asset { display: none; }
}
@media (max-width: 900px) {
.tx-row { grid-template-columns: auto 1fr auto; }
.tx-time { display: none; }
}
</style>
}