feat: sending money / coins
This commit is contained in:
parent
4e58d20e84
commit
de0987a007
14 changed files with 2825 additions and 325 deletions
|
|
@ -6,7 +6,7 @@
|
|||
import CoinIcon from './CoinIcon.svelte';
|
||||
import UserProfilePreview from './UserProfilePreview.svelte';
|
||||
import { getPublicUrl } from '$lib/utils';
|
||||
|
||||
import { ArrowUp, ArrowDown } from 'lucide-svelte';
|
||||
interface Column {
|
||||
key: string;
|
||||
label: string;
|
||||
|
|
@ -34,13 +34,63 @@
|
|||
emptyDescription?: string;
|
||||
enableUserPreview?: boolean;
|
||||
} = $props();
|
||||
function renderCell(column: any, row: any, value: any, index: number) {
|
||||
if (column.render) {
|
||||
const rendered = column.render(value, row, index);
|
||||
if (rendered?.component === 'badge') {
|
||||
return {
|
||||
type: 'badge',
|
||||
variant: rendered.variant || 'default',
|
||||
text: rendered.text,
|
||||
icon: rendered.icon,
|
||||
class: rendered.class || ''
|
||||
};
|
||||
}
|
||||
if (rendered?.component === 'coin') {
|
||||
return {
|
||||
type: 'coin',
|
||||
icon: rendered.icon,
|
||||
symbol: rendered.symbol,
|
||||
name: rendered.name,
|
||||
size: rendered.size || 6
|
||||
};
|
||||
}
|
||||
if (rendered?.component === 'text') {
|
||||
return {
|
||||
type: 'text',
|
||||
text: rendered.text,
|
||||
class: rendered.class || ''
|
||||
};
|
||||
}
|
||||
if (rendered?.component === 'rank') {
|
||||
return {
|
||||
type: 'rank',
|
||||
icon: rendered.icon,
|
||||
color: rendered.color,
|
||||
number: rendered.number
|
||||
};
|
||||
}
|
||||
if (rendered?.component === 'user') {
|
||||
return {
|
||||
type: 'user',
|
||||
image: rendered.image,
|
||||
name: rendered.name,
|
||||
username: rendered.username
|
||||
};
|
||||
}
|
||||
if (typeof rendered === 'string') {
|
||||
return { type: 'text', text: rendered };
|
||||
}
|
||||
}
|
||||
return { type: 'text', text: value };
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if data.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
{#if emptyIcon}
|
||||
<div class="bg-muted mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<svelte:component this={emptyIcon} class="text-muted-foreground h-6 w-6" />
|
||||
<emptyIcon class="text-muted-foreground h-6 w-6"></emptyIcon>
|
||||
</div>
|
||||
{/if}
|
||||
<h3 class="mb-2 text-lg font-semibold">{emptyTitle}</h3>
|
||||
|
|
@ -50,99 +100,79 @@
|
|||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each columns as column}
|
||||
{#each columns as column (column.key)}
|
||||
<Table.Head class={column.class || 'min-w-[80px]'}>{column.label}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each data as row, index}
|
||||
</Table.Header><Table.Body>
|
||||
{#each data as row, index (row.symbol || row.id || index)}
|
||||
<Table.Row
|
||||
class={onRowClick ? 'hover:bg-muted/50 cursor-pointer transition-colors' : ''}
|
||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{#each columns as column}
|
||||
{#each columns as column (column.key)}
|
||||
<Table.Cell class={column.class}>
|
||||
{#if column.render}
|
||||
{@const rendered = column.render(row[column.key], row, index)}
|
||||
{#if typeof rendered === 'object' && rendered !== null}
|
||||
{#if rendered.component === 'badge'}
|
||||
<Badge variant={rendered.variant} class={rendered.class}>
|
||||
{rendered.text}
|
||||
</Badge>
|
||||
{:else if rendered.component === 'user'}
|
||||
{#if enableUserPreview}
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar.Root class="h-6 w-6">
|
||||
<Avatar.Image
|
||||
src={getPublicUrl(rendered.image)}
|
||||
alt={rendered.name}
|
||||
/>
|
||||
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs">
|
||||
{rendered.name?.charAt(0) || '?'}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{rendered.name}</p>
|
||||
<p class="text-muted-foreground text-xs">@{rendered.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80" side="right" sideOffset={10}>
|
||||
<UserProfilePreview userId={row.userId || row.id} />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
{:else}
|
||||
{@const cellData = renderCell(column, row, row[column.key], index)}
|
||||
{#if cellData.type === 'badge'}
|
||||
<Badge variant={cellData.variant} class={cellData.class}>
|
||||
{#if cellData.icon === 'arrow-up'}
|
||||
<ArrowUp class="mr-1 h-3 w-3" />
|
||||
{:else if cellData.icon === 'arrow-down'}
|
||||
<ArrowDown class="mr-1 h-3 w-3" />
|
||||
{/if}
|
||||
{cellData.text}
|
||||
</Badge>
|
||||
{:else if cellData.type === 'coin'}
|
||||
<div class="flex items-center gap-2">
|
||||
<CoinIcon icon={cellData.icon} symbol={cellData.symbol} size={cellData.size} />
|
||||
<span class="font-medium">{cellData.name}</span>
|
||||
</div>
|
||||
{:else if cellData.type === 'rank'}
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class={`${cellData.color} flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium`}
|
||||
>
|
||||
<svelte:component this={cellData.icon} class="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span class="text-sm font-medium">#{cellData.number}</span>
|
||||
</div>
|
||||
{:else if cellData.type === 'user'}
|
||||
{#if enableUserPreview}
|
||||
<HoverCard.Root>
|
||||
<HoverCard.Trigger>
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar.Root class="h-6 w-6">
|
||||
<Avatar.Image src={getPublicUrl(rendered.image)} alt={rendered.name} />
|
||||
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs">
|
||||
{rendered.name?.charAt(0) || '?'}
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image src={getPublicUrl(cellData.image)} alt={cellData.name} />
|
||||
<Avatar.Fallback class="text-xs">
|
||||
{cellData.name?.charAt(0) || '?'}
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{rendered.name}</p>
|
||||
<p class="text-muted-foreground text-xs">@{rendered.username}</p>
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-sm font-medium">{cellData.name}</span>
|
||||
<span class="text-muted-foreground text-xs">@{cellData.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if rendered.component === 'rank'}
|
||||
<div class="flex items-center gap-2">
|
||||
<svelte:component this={rendered.icon} class="h-4 w-4 {rendered.color}" />
|
||||
<span class="font-mono text-sm">#{rendered.number}</span>
|
||||
</div>
|
||||
{:else if rendered.component === 'coin'}
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<CoinIcon
|
||||
icon={rendered.icon}
|
||||
symbol={rendered.symbol}
|
||||
name={rendered.name}
|
||||
size={rendered.size || 6}
|
||||
/>
|
||||
<div class="truncate">
|
||||
<div class="truncate font-medium">{rendered.name}</div>
|
||||
<div class="text-muted-foreground text-sm">*{rendered.symbol}</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if rendered.component === 'link'}
|
||||
<a href={rendered.href} class="flex items-center gap-1 hover:underline">
|
||||
<CoinIcon
|
||||
icon={rendered.content.icon}
|
||||
symbol={rendered.content.symbol}
|
||||
name={rendered.content.name}
|
||||
size={4}
|
||||
/>
|
||||
{rendered.content.name}
|
||||
<span class="text-muted-foreground">(*{rendered.content.symbol})</span>
|
||||
</a>
|
||||
{/if}
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="w-80">
|
||||
<UserProfilePreview userId={row.userId} />
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
{:else}
|
||||
{rendered}
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image src={getPublicUrl(cellData.image)} alt={cellData.name} />
|
||||
<Avatar.Fallback class="text-xs"
|
||||
>{cellData.name?.charAt(0) || '?'}</Avatar.Fallback
|
||||
>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col items-start">
|
||||
<span class="text-sm font-medium">{cellData.name}</span>
|
||||
<span class="text-muted-foreground text-xs">@{cellData.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{row[column.key]}
|
||||
{:else if cellData.type === 'text'}
|
||||
<span class={cellData.class}>{cellData.text}</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
|
|
|
|||
316
website/src/lib/components/self/SendMoneyModal.svelte
Normal file
316
website/src/lib/components/self/SendMoneyModal.svelte
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Send, DollarSign, Coins, Loader2 } from 'lucide-svelte';
|
||||
import { PORTFOLIO_DATA } from '$lib/stores/portfolio-data';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onSuccess,
|
||||
prefilledUsername = ''
|
||||
} = $props<{
|
||||
open?: boolean;
|
||||
onSuccess?: () => void;
|
||||
prefilledUsername?: string;
|
||||
}>();
|
||||
|
||||
let recipientUsername = $state('');
|
||||
let transferType = $state('CASH');
|
||||
let amount = $state('');
|
||||
let selectedCoinSymbol = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
let numericAmount = $derived(parseFloat(amount) || 0);
|
||||
let hasValidAmount = $derived(numericAmount > 0);
|
||||
let hasValidRecipient = $derived(recipientUsername.trim().length > 0);
|
||||
let userBalance = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance : 0);
|
||||
let coinHoldings = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.coinHoldings : []);
|
||||
|
||||
let selectedCoinHolding = $derived(
|
||||
coinHoldings.find((holding) => holding.symbol === selectedCoinSymbol)
|
||||
);
|
||||
|
||||
let maxAmount = $derived(
|
||||
transferType === 'CASH' ? userBalance : selectedCoinHolding ? selectedCoinHolding.quantity : 0
|
||||
);
|
||||
|
||||
let hasEnoughFunds = $derived(
|
||||
transferType === 'CASH'
|
||||
? numericAmount <= userBalance
|
||||
: selectedCoinHolding
|
||||
? numericAmount <= selectedCoinHolding.quantity
|
||||
: false
|
||||
);
|
||||
|
||||
let canSend = $derived(
|
||||
hasValidAmount &&
|
||||
hasValidRecipient &&
|
||||
hasEnoughFunds &&
|
||||
!loading &&
|
||||
(transferType === 'CASH' || selectedCoinSymbol.length > 0)
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
recipientUsername = '';
|
||||
transferType = 'CASH';
|
||||
amount = '';
|
||||
selectedCoinSymbol = '';
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function setMaxAmount() {
|
||||
amount = maxAmount.toString();
|
||||
}
|
||||
|
||||
function handleTypeChange(value: string) {
|
||||
transferType = value;
|
||||
if (value === 'CASH') {
|
||||
selectedCoinSymbol = '';
|
||||
} else if (coinHoldings.length > 0) {
|
||||
selectedCoinSymbol = coinHoldings[0].symbol;
|
||||
}
|
||||
amount = '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && prefilledUsername) {
|
||||
recipientUsername = prefilledUsername;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSend() {
|
||||
if (!canSend) return;
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/transfer', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipientUsername: recipientUsername.trim(),
|
||||
type: transferType,
|
||||
amount: numericAmount,
|
||||
coinSymbol: transferType === 'COIN' ? selectedCoinSymbol : undefined
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Transfer failed');
|
||||
}
|
||||
|
||||
if (result.type === 'CASH') {
|
||||
toast.success('Money sent successfully!', {
|
||||
description: `Sent $${result.amount.toFixed(2)} to @${result.recipient}`
|
||||
});
|
||||
} else {
|
||||
const estimatedValueForToast = estimatedValue;
|
||||
toast.success('Coins sent successfully!', {
|
||||
description: `Sent ${result.amount.toFixed(6)} ${result.coinSymbol} (≈$${estimatedValueForToast.toFixed(2)}) to @${result.recipient}`
|
||||
});
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
toast.error('Transfer failed', {
|
||||
description: (e as Error).message
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let transferTypeOptions = [
|
||||
{ value: 'CASH', label: 'Cash ($)' },
|
||||
{ value: 'COIN', label: 'Coins' }
|
||||
];
|
||||
|
||||
let currentTransferTypeLabel = $derived(
|
||||
transferTypeOptions.find((option) => option.value === transferType)?.label ||
|
||||
'Select transfer type'
|
||||
);
|
||||
|
||||
let currentCoinLabel = $derived(
|
||||
!selectedCoinSymbol
|
||||
? 'Select coin to send'
|
||||
: (() => {
|
||||
const holding = coinHoldings.find((h) => h.symbol === selectedCoinSymbol);
|
||||
return holding
|
||||
? `*${holding.symbol} (${holding.quantity.toFixed(6)} available)`
|
||||
: selectedCoinSymbol;
|
||||
})()
|
||||
);
|
||||
|
||||
let estimatedValue = $derived(
|
||||
transferType === 'COIN' && selectedCoinHolding && numericAmount > 0
|
||||
? numericAmount * selectedCoinHolding.currentPrice
|
||||
: 0
|
||||
);
|
||||
|
||||
function handleCoinChange(value: string) {
|
||||
selectedCoinSymbol = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
<Send class="h-5 w-5" />
|
||||
Send
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>Send cash or coins to another user</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Recipient Username -->
|
||||
<div class="space-y-2">
|
||||
<Label for="recipient">Recipient</Label>
|
||||
<Input
|
||||
id="recipient"
|
||||
type="text"
|
||||
bind:value={recipientUsername}
|
||||
placeholder="Enter username (without @)"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Transfer Type -->
|
||||
<div class="space-y-2">
|
||||
<Label>Type</Label>
|
||||
<Select.Root type="single" bind:value={transferType} onValueChange={handleTypeChange}>
|
||||
<Select.Trigger class="w-full">
|
||||
{currentTransferTypeLabel}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Item value="CASH" label="Cash ($)">
|
||||
<div class="flex items-center gap-2">
|
||||
<DollarSign class="h-4 w-4" />
|
||||
Cash ($)
|
||||
</div>
|
||||
</Select.Item>
|
||||
<Select.Item value="COIN" label="Coins" disabled={coinHoldings.length === 0}>
|
||||
<div class="flex items-center gap-2">
|
||||
<Coins class="h-4 w-4" />
|
||||
Coins
|
||||
</div>
|
||||
</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Coin Selection (if coin transfer) -->
|
||||
{#if transferType === 'COIN'}
|
||||
<div class="space-y-2">
|
||||
<Label>Select Coin</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={selectedCoinSymbol}
|
||||
onValueChange={handleCoinChange}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{currentCoinLabel}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each coinHoldings as holding}
|
||||
<Select.Item value={holding.symbol} label="*{holding.symbol}">
|
||||
*{holding.symbol} ({holding.quantity.toFixed(6)} available)
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Amount Input -->
|
||||
<div class="space-y-2">
|
||||
<Label for="amount">
|
||||
{transferType === 'CASH' ? 'Amount ($)' : `Amount (${selectedCoinSymbol})`}
|
||||
</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step={transferType === 'CASH' ? '0.01' : '0.000001'}
|
||||
min="0"
|
||||
bind:value={amount}
|
||||
placeholder="0.00"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onclick={setMaxAmount}>Max</Button>
|
||||
</div>
|
||||
<div class="flex justify-between text-xs">
|
||||
<p class="text-muted-foreground">
|
||||
Available: {transferType === 'CASH'
|
||||
? `$${userBalance.toFixed(2)}`
|
||||
: selectedCoinHolding
|
||||
? `${selectedCoinHolding.quantity.toFixed(6)} ${selectedCoinSymbol}`
|
||||
: '0'}
|
||||
</p>
|
||||
{#if transferType === 'COIN' && estimatedValue > 0}
|
||||
<p class="text-muted-foreground">
|
||||
≈ ${estimatedValue.toFixed(2)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !hasEnoughFunds && hasValidAmount}
|
||||
<Badge variant="destructive" class="text-xs">
|
||||
Insufficient {transferType === 'CASH' ? 'funds' : 'coins'}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if hasValidAmount && hasEnoughFunds && hasValidRecipient}
|
||||
<div class="bg-muted/50 rounded-lg p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium">You're sending:</span>
|
||||
<div class="text-right">
|
||||
<span class="block font-bold">
|
||||
{transferType === 'CASH'
|
||||
? `$${numericAmount.toFixed(2)}`
|
||||
: `${numericAmount.toFixed(6)} ${selectedCoinSymbol}`}
|
||||
</span>
|
||||
{#if transferType === 'COIN' && estimatedValue > 0}
|
||||
<span class="text-muted-foreground text-xs">
|
||||
≈ ${estimatedValue.toFixed(2)} USD
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium">To:</span>
|
||||
<span class="font-bold">@{recipientUsername}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="flex gap-2">
|
||||
<Button variant="outline" onclick={handleClose} disabled={loading}>Cancel</Button>
|
||||
<Button onclick={handleSend} disabled={!canSend}>
|
||||
{#if loading}
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
Sending...
|
||||
{:else}
|
||||
<Send class="h-4 w-4" />
|
||||
Send
|
||||
{/if}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
Reference in a new issue