beeta/frontend/src/lib/components/UbercoinTipModal.svelte
doomtube 01bd631af8 Fix: Ubercoin balance display and refresh issues
- Profile page: Add cache: 'no-store' to always fetch fresh balance data
- Profile page: Update balance immediately after tip using transaction result
  to avoid race conditions where DB hasn't committed yet
- Profile page: Reload profile after 500ms delay for data consistency
- UbercoinTipModal: Clarify "Your balance" to avoid confusion with recipient's

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 04:52:41 -05:00

467 lines
12 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { ubercoinBalance, previewTransaction, sendUbercoin, formatUbercoin } from '$lib/stores/ubercoin';
export let recipientUsername = '';
export let show = false;
const dispatch = createEventDispatcher();
let amount = '';
let preview = null;
let loading = false;
let previewLoading = false;
let error = '';
let success = false;
let panelElement;
let clickOutsideEnabled = false;
// Debounced preview
let previewTimeout;
$: if (show && amount && parseFloat(amount) > 0) {
clearTimeout(previewTimeout);
previewLoading = true;
previewTimeout = setTimeout(async () => {
preview = await previewTransaction(recipientUsername, parseFloat(amount));
previewLoading = false;
}, 300);
} else {
preview = null;
previewLoading = false;
}
// Reset state when panel opens
$: if (show) {
amount = '';
preview = null;
loading = false;
error = '';
success = false;
clickOutsideEnabled = false;
// Enable click-outside after a brief delay to prevent immediate closing
setTimeout(() => { clickOutsideEnabled = true; }, 100);
} else {
clickOutsideEnabled = false;
}
onMount(() => {
document.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleClickOutside);
});
onDestroy(() => {
document.removeEventListener('keydown', handleKeydown);
document.removeEventListener('click', handleClickOutside);
clearTimeout(previewTimeout);
});
function handleKeydown(event) {
if (!show) return;
if (event.key === 'Escape') {
handleClose();
}
}
function handleClickOutside(event) {
if (!show || loading || !clickOutsideEnabled) return;
if (panelElement && !panelElement.contains(event.target)) {
handleClose();
}
}
async function handleSend() {
const parsedAmount = parseFloat(amount);
if (!amount || parsedAmount <= 0) {
error = 'Enter a valid amount';
return;
}
if (parsedAmount > $ubercoinBalance) {
error = 'Insufficient balance';
return;
}
loading = true;
error = '';
const result = await sendUbercoin(recipientUsername, parsedAmount);
loading = false;
if (result.success) {
success = true;
setTimeout(() => {
dispatch('close');
dispatch('sent', result);
}, 1500);
} else {
error = result.error || 'Failed to send';
}
}
function handleClose() {
if (!loading) {
dispatch('close');
}
}
</script>
<style>
.tip-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: #1a1a1a;
border: 1px solid var(--border, #333);
border-radius: 8px;
width: 300px;
max-width: 90vw;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
animation: fadeIn 0.15s ease-out;
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translate(-50%, -50%) translateY(-5px);
}
to {
opacity: 1;
transform: translate(-50%, -50%) translateY(0);
}
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border, #333);
background: rgba(255, 215, 0, 0.05);
}
.panel-header h3 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: white;
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border-radius: 50%;
font-size: 0.6rem;
font-weight: bold;
color: #000;
}
.close-btn {
background: transparent;
border: none;
color: #666;
font-size: 1.1rem;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
border-radius: 4px;
transition: all 0.15s ease;
}
.close-btn:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
.panel-body {
padding: 14px;
}
.recipient-info {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 12px;
font-size: 0.85rem;
}
.recipient-info span {
color: #888;
}
.recipient-info strong {
color: white;
}
.balance-row {
display: flex;
align-items: center;
gap: 6px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin-bottom: 12px;
font-size: 0.85rem;
color: #ccc;
}
.balance-row strong {
color: #ffd700;
}
.coin-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border-radius: 50%;
font-size: 0.6rem;
font-weight: bold;
color: #000;
}
.input-group {
margin-bottom: 12px;
}
.input-group label {
display: block;
font-size: 0.8rem;
color: #888;
margin-bottom: 4px;
}
.input-group input {
width: 100%;
padding: 8px 10px;
background: #222;
border: 1px solid var(--border, #333);
border-radius: 4px;
color: white;
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s ease;
}
.input-group input:focus {
border-color: #ffd700;
}
.input-group input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.burn-info-box {
display: flex;
gap: 10px;
padding: 10px;
background: rgba(255, 152, 0, 0.1);
border: 1px solid rgba(255, 152, 0, 0.3);
border-radius: 6px;
margin-bottom: 12px;
}
.warning-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: #ff9800;
color: #000;
border-radius: 50%;
font-weight: bold;
font-size: 0.75rem;
flex-shrink: 0;
}
.burn-details {
flex: 1;
}
.burn-details strong {
display: block;
color: #ff9800;
font-size: 0.85rem;
margin-bottom: 2px;
}
.burn-details p {
margin: 0 0 2px 0;
font-size: 0.8rem;
color: #ccc;
}
.burn-details .detail-small {
font-size: 0.75rem;
color: #888;
}
.preview-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 10px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
margin-bottom: 12px;
font-size: 0.8rem;
color: #888;
}
.error-message {
padding: 8px 10px;
background: rgba(244, 67, 54, 0.1);
border: 1px solid rgba(244, 67, 54, 0.3);
border-radius: 4px;
color: #f44336;
font-size: 0.8rem;
margin-bottom: 12px;
}
.success-message {
padding: 8px 10px;
background: rgba(76, 175, 80, 0.1);
border: 1px solid rgba(76, 175, 80, 0.3);
border-radius: 4px;
color: #4caf50;
font-size: 0.8rem;
margin-bottom: 12px;
text-align: center;
}
.panel-footer {
display: flex;
gap: 10px;
padding: 12px 14px;
border-top: 1px solid var(--border, #333);
}
.panel-footer button {
flex: 1;
padding: 8px 12px;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border, #333);
color: white;
}
.cancel-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.send-btn {
background: linear-gradient(135deg, #ffd700 0%, #ffb300 100%);
border: none;
color: #000;
font-weight: 600;
}
.send-btn:hover:not(:disabled) {
filter: brightness(1.1);
}
.send-btn:disabled,
.cancel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
{#if show}
<div class="tip-panel" bind:this={panelElement} role="dialog" aria-modal="true">
<div class="panel-header">
<h3>
<span class="header-icon">Ü</span>
Send übercoin
</h3>
<button class="close-btn" on:click={handleClose} disabled={loading}>×</button>
</div>
<div class="panel-body">
<div class="recipient-info">
<span>To:</span>
<strong>{recipientUsername}</strong>
</div>
<div class="balance-row">
<span class="coin-icon">Ü</span>
<span>Your balance: <strong>{formatUbercoin($ubercoinBalance)}</strong></span>
</div>
<div class="input-group">
<label for="tip-amount">Amount</label>
<input
type="number"
id="tip-amount"
bind:value={amount}
placeholder="0.000"
step="0.001"
min="0.001"
disabled={loading || success}
/>
</div>
{#if previewLoading}
<div class="preview-loading">
Calculating burn rate...
</div>
{:else if preview && preview.success && preview.burnRatePercent > 0}
<div class="burn-info-box">
<span class="warning-icon">!</span>
<div class="burn-details">
<strong>{preview.burnRatePercent.toFixed(1)}% burn rate</strong>
<p>{recipientUsername} receives <strong>{formatUbercoin(preview.receivedAmount)}</strong> UC</p>
<p class="detail-small">{formatUbercoin(preview.burnedAmount)} UC → Treasury ({preview.recipientAccountAgeDays}d old)</p>
</div>
</div>
{/if}
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if success}
<div class="success-message">Sent successfully!</div>
{/if}
</div>
<div class="panel-footer">
<button class="cancel-btn" on:click={handleClose} disabled={loading}>
Cancel
</button>
<button
class="send-btn"
on:click={handleSend}
disabled={loading || success || !amount || parseFloat(amount) <= 0}
>
{#if loading}
Sending...
{:else}
Send
{/if}
</button>
</div>
</div>
{/if}