feat: sending money / coins
This commit is contained in:
parent
4e58d20e84
commit
de0987a007
14 changed files with 2825 additions and 325 deletions
15
website/drizzle/0001_yummy_meggan.sql
Normal file
15
website/drizzle/0001_yummy_meggan.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
ALTER TYPE "transaction_type" ADD VALUE 'TRANSFER_IN';--> statement-breakpoint
|
||||||
|
ALTER TYPE "transaction_type" ADD VALUE 'TRANSFER_OUT';--> statement-breakpoint
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN "recipient_user_id" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "transaction" ADD COLUMN "sender_user_id" integer;--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_recipient_user_id_user_id_fk" FOREIGN KEY ("recipient_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE "transaction" ADD CONSTRAINT "transaction_sender_user_id_user_id_fk" FOREIGN KEY ("sender_user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
1559
website/drizzle/meta/0001_snapshot.json
Normal file
1559
website/drizzle/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1748604150899,
|
"when": 1748604150899,
|
||||||
"tag": "0000_spooky_umar",
|
"tag": "0000_spooky_umar",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1748690470287,
|
||||||
|
"tag": "0001_yummy_meggan",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -73,5 +73,9 @@ export async function handle({ event, resolve }) {
|
||||||
// 'Cache-Control': 'private, no-cache, no-store, must-revalidate'
|
// 'Cache-Control': 'private, no-cache, no-store, must-revalidate'
|
||||||
// });
|
// });
|
||||||
|
|
||||||
|
if (event.url.pathname.startsWith('/.well-known/appspecific/com.chrome.devtools')) {
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
return svelteKitHandler({ event, resolve, auth });
|
return svelteKitHandler({ event, resolve, auth });
|
||||||
}
|
}
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
import CoinIcon from './CoinIcon.svelte';
|
import CoinIcon from './CoinIcon.svelte';
|
||||||
import UserProfilePreview from './UserProfilePreview.svelte';
|
import UserProfilePreview from './UserProfilePreview.svelte';
|
||||||
import { getPublicUrl } from '$lib/utils';
|
import { getPublicUrl } from '$lib/utils';
|
||||||
|
import { ArrowUp, ArrowDown } from 'lucide-svelte';
|
||||||
interface Column {
|
interface Column {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -34,13 +34,63 @@
|
||||||
emptyDescription?: string;
|
emptyDescription?: string;
|
||||||
enableUserPreview?: boolean;
|
enableUserPreview?: boolean;
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
{#if data.length === 0}
|
{#if data.length === 0}
|
||||||
<div class="py-12 text-center">
|
<div class="py-12 text-center">
|
||||||
{#if emptyIcon}
|
{#if emptyIcon}
|
||||||
<div class="bg-muted mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<h3 class="mb-2 text-lg font-semibold">{emptyTitle}</h3>
|
<h3 class="mb-2 text-lg font-semibold">{emptyTitle}</h3>
|
||||||
|
|
@ -50,99 +100,79 @@
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
{#each columns as column}
|
{#each columns as column (column.key)}
|
||||||
<Table.Head class={column.class || 'min-w-[80px]'}>{column.label}</Table.Head>
|
<Table.Head class={column.class || 'min-w-[80px]'}>{column.label}</Table.Head>
|
||||||
{/each}
|
{/each}
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
</Table.Header>
|
</Table.Header><Table.Body>
|
||||||
<Table.Body>
|
{#each data as row, index (row.symbol || row.id || index)}
|
||||||
{#each data as row, index}
|
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class={onRowClick ? 'hover:bg-muted/50 cursor-pointer transition-colors' : ''}
|
class={onRowClick ? 'hover:bg-muted/50 cursor-pointer transition-colors' : ''}
|
||||||
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
onclick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
>
|
>
|
||||||
{#each columns as column}
|
{#each columns as column (column.key)}
|
||||||
<Table.Cell class={column.class}>
|
<Table.Cell class={column.class}>
|
||||||
{#if column.render}
|
{@const cellData = renderCell(column, row, row[column.key], index)}
|
||||||
{@const rendered = column.render(row[column.key], row, index)}
|
{#if cellData.type === 'badge'}
|
||||||
{#if typeof rendered === 'object' && rendered !== null}
|
<Badge variant={cellData.variant} class={cellData.class}>
|
||||||
{#if rendered.component === 'badge'}
|
{#if cellData.icon === 'arrow-up'}
|
||||||
<Badge variant={rendered.variant} class={rendered.class}>
|
<ArrowUp class="mr-1 h-3 w-3" />
|
||||||
{rendered.text}
|
{:else if cellData.icon === 'arrow-down'}
|
||||||
</Badge>
|
<ArrowDown class="mr-1 h-3 w-3" />
|
||||||
{:else if rendered.component === 'user'}
|
{/if}
|
||||||
{#if enableUserPreview}
|
{cellData.text}
|
||||||
<HoverCard.Root>
|
</Badge>
|
||||||
<HoverCard.Trigger>
|
{:else if cellData.type === 'coin'}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar.Root class="h-6 w-6">
|
<CoinIcon icon={cellData.icon} symbol={cellData.symbol} size={cellData.size} />
|
||||||
<Avatar.Image
|
<span class="font-medium">{cellData.name}</span>
|
||||||
src={getPublicUrl(rendered.image)}
|
</div>
|
||||||
alt={rendered.name}
|
{:else if cellData.type === 'rank'}
|
||||||
/>
|
<div class="flex items-center gap-2">
|
||||||
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs">
|
<div
|
||||||
{rendered.name?.charAt(0) || '?'}
|
class={`${cellData.color} flex h-7 w-7 items-center justify-center rounded-full text-xs font-medium`}
|
||||||
</Avatar.Fallback>
|
>
|
||||||
</Avatar.Root>
|
<svelte:component this={cellData.icon} class="h-3.5 w-3.5" />
|
||||||
<div>
|
</div>
|
||||||
<p class="text-sm font-medium">{rendered.name}</p>
|
<span class="text-sm font-medium">#{cellData.number}</span>
|
||||||
<p class="text-muted-foreground text-xs">@{rendered.username}</p>
|
</div>
|
||||||
</div>
|
{:else if cellData.type === 'user'}
|
||||||
</div>
|
{#if enableUserPreview}
|
||||||
</HoverCard.Trigger>
|
<HoverCard.Root>
|
||||||
<HoverCard.Content class="w-80" side="right" sideOffset={10}>
|
<HoverCard.Trigger>
|
||||||
<UserProfilePreview userId={row.userId || row.id} />
|
|
||||||
</HoverCard.Content>
|
|
||||||
</HoverCard.Root>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar.Root class="h-6 w-6">
|
<Avatar.Root class="h-7 w-7">
|
||||||
<Avatar.Image src={getPublicUrl(rendered.image)} alt={rendered.name} />
|
<Avatar.Image src={getPublicUrl(cellData.image)} alt={cellData.name} />
|
||||||
<Avatar.Fallback class="bg-muted text-muted-foreground text-xs">
|
<Avatar.Fallback class="text-xs">
|
||||||
{rendered.name?.charAt(0) || '?'}
|
{cellData.name?.charAt(0) || '?'}
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div>
|
<div class="flex flex-col items-start">
|
||||||
<p class="text-sm font-medium">{rendered.name}</p>
|
<span class="text-sm font-medium">{cellData.name}</span>
|
||||||
<p class="text-muted-foreground text-xs">@{rendered.username}</p>
|
<span class="text-muted-foreground text-xs">@{cellData.username}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</HoverCard.Trigger>
|
||||||
{:else if rendered.component === 'rank'}
|
<HoverCard.Content class="w-80">
|
||||||
<div class="flex items-center gap-2">
|
<UserProfilePreview userId={row.userId} />
|
||||||
<svelte:component this={rendered.icon} class="h-4 w-4 {rendered.color}" />
|
</HoverCard.Content>
|
||||||
<span class="font-mono text-sm">#{rendered.number}</span>
|
</HoverCard.Root>
|
||||||
</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}
|
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
{:else}
|
{:else if cellData.type === 'text'}
|
||||||
{row[column.key]}
|
<span class={cellData.class}>{cellData.text}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{/each}
|
{/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>
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index, unique, check } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp, boolean, decimal, serial, varchar, integer, primaryKey, pgEnum, index, unique, check } from "drizzle-orm/pg-core";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL']);
|
export const transactionTypeEnum = pgEnum('transaction_type', ['BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT']);
|
||||||
export const predictionMarketEnum = pgEnum('prediction_market_status', ['ACTIVE', 'RESOLVED', 'CANCELLED']);
|
export const predictionMarketEnum = pgEnum('prediction_market_status', ['ACTIVE', 'RESOLVED', 'CANCELLED']);
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
|
|
@ -110,6 +110,8 @@ export const transaction = pgTable("transaction", {
|
||||||
pricePerCoin: decimal("price_per_coin", { precision: 20, scale: 8 }).notNull(),
|
pricePerCoin: decimal("price_per_coin", { precision: 20, scale: 8 }).notNull(),
|
||||||
totalBaseCurrencyAmount: decimal("total_base_currency_amount", { precision: 30, scale: 8 }).notNull(),
|
totalBaseCurrencyAmount: decimal("total_base_currency_amount", { precision: 30, scale: 8 }).notNull(),
|
||||||
timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(),
|
timestamp: timestamp("timestamp", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
recipientUserId: integer('recipient_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||||
|
senderUserId: integer('sender_user_id').references(() => user.id, { onDelete: 'set null' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const priceHistory = pgTable("price_history", {
|
export const priceHistory = pgTable("price_history", {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const marketColumns = [
|
const marketColumns = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
|
|
@ -40,13 +39,11 @@
|
||||||
class: 'font-medium',
|
class: 'font-medium',
|
||||||
render: (value: any, row: any) => {
|
render: (value: any, row: any) => {
|
||||||
return {
|
return {
|
||||||
component: 'link',
|
component: 'coin',
|
||||||
href: `/coin/${row.symbol}`,
|
icon: row.icon,
|
||||||
content: {
|
symbol: row.symbol,
|
||||||
icon: row.icon,
|
name: row.name,
|
||||||
symbol: row.symbol,
|
size: 6
|
||||||
name: row.name
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -116,9 +113,8 @@
|
||||||
<p class="text-muted-foreground text-sm">Be the first to create a coin!</p>
|
<p class="text-muted-foreground text-sm">Be the first to create a coin!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else} <div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
{#each coins.slice(0, 6) as coin (coin.symbol)}
|
||||||
{#each coins.slice(0, 6) as coin}
|
|
||||||
<a href={`/coin/${coin.symbol}`} class="block">
|
<a href={`/coin/${coin.symbol}`} class="block">
|
||||||
<Card.Root class="hover:bg-card/50 h-full transition-all hover:shadow-md">
|
<Card.Root class="hover:bg-card/50 h-full transition-all hover:shadow-md">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { auth } from '$lib/auth';
|
import { auth } from '$lib/auth';
|
||||||
import { error, json } from '@sveltejs/kit';
|
import { error, json } from '@sveltejs/kit';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { transaction, coin } from '$lib/server/db/schema';
|
import { transaction, coin, user } from '$lib/server/db/schema';
|
||||||
import { eq, desc, asc, and, or, ilike, sql } from 'drizzle-orm';
|
import { eq, desc, asc, and, or, ilike, sql } from 'drizzle-orm';
|
||||||
|
import { alias } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
export async function GET({ request, url }) {
|
export async function GET({ request, url }) {
|
||||||
const authSession = await auth.api.getSession({
|
const authSession = await auth.api.getSession({
|
||||||
|
|
@ -18,25 +19,43 @@ export async function GET({ request, url }) {
|
||||||
const typeFilter = url.searchParams.get('type') || 'all';
|
const typeFilter = url.searchParams.get('type') || 'all';
|
||||||
const sortBy = url.searchParams.get('sortBy') || 'timestamp';
|
const sortBy = url.searchParams.get('sortBy') || 'timestamp';
|
||||||
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
|
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||||
const page = parseInt(url.searchParams.get('page') || '1');
|
|
||||||
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
|
|
||||||
|
|
||||||
let whereConditions = and(eq(transaction.userId, userId));
|
// Validate page parameter
|
||||||
|
const pageParam = url.searchParams.get('page') || '1';
|
||||||
|
const page = parseInt(pageParam);
|
||||||
|
if (isNaN(page) || page < 1) {
|
||||||
|
throw error(400, 'Invalid page parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate limit parameter
|
||||||
|
const limitParam = url.searchParams.get('limit') || '20';
|
||||||
|
const parsedLimit = parseInt(limitParam);
|
||||||
|
const limit = isNaN(parsedLimit) ? 20 : Math.min(Math.max(parsedLimit, 1), 50); const recipientUser = alias(user, 'recipientUser');
|
||||||
|
|
||||||
|
const senderUser = alias(user, 'senderUser');
|
||||||
|
|
||||||
|
const conditions = [eq(transaction.userId, userId)];
|
||||||
|
|
||||||
if (searchQuery) {
|
if (searchQuery) {
|
||||||
whereConditions = and(
|
conditions.push(
|
||||||
whereConditions,
|
|
||||||
or(
|
or(
|
||||||
ilike(coin.name, `%${searchQuery}%`),
|
ilike(coin.name, `%${searchQuery}%`),
|
||||||
ilike(coin.symbol, `%${searchQuery}%`)
|
ilike(coin.symbol, `%${searchQuery}%`)
|
||||||
)
|
)!
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeFilter !== 'all') {
|
if (typeFilter !== 'all') {
|
||||||
whereConditions = and(whereConditions, eq(transaction.type, typeFilter as 'BUY' | 'SELL'));
|
const validTypes = ['BUY', 'SELL', 'TRANSFER_IN', 'TRANSFER_OUT'] as const;
|
||||||
|
if (validTypes.includes(typeFilter as any)) {
|
||||||
|
conditions.push(eq(transaction.type, typeFilter as typeof validTypes[number]));
|
||||||
|
} else {
|
||||||
|
throw error(400, `Invalid type parameter. Allowed: ${validTypes.join(', ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const whereConditions = conditions.length === 1 ? conditions[0] : and(...conditions);
|
||||||
|
|
||||||
let sortColumn;
|
let sortColumn;
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'totalBaseCurrencyAmount':
|
case 'totalBaseCurrencyAmount':
|
||||||
|
|
@ -52,12 +71,10 @@ export async function GET({ request, url }) {
|
||||||
sortColumn = transaction.timestamp;
|
sortColumn = transaction.timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn);
|
const orderBy = sortOrder === 'asc' ? asc(sortColumn) : desc(sortColumn); const [{ count }] = await db
|
||||||
|
|
||||||
const [{ count }] = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
.select({ count: sql<number>`count(*)` })
|
||||||
.from(transaction)
|
.from(transaction)
|
||||||
.leftJoin(coin, eq(transaction.coinId, coin.id))
|
.innerJoin(coin, eq(transaction.coinId, coin.id))
|
||||||
.where(whereConditions);
|
.where(whereConditions);
|
||||||
|
|
||||||
const transactions = await db
|
const transactions = await db
|
||||||
|
|
@ -68,26 +85,64 @@ export async function GET({ request, url }) {
|
||||||
pricePerCoin: transaction.pricePerCoin,
|
pricePerCoin: transaction.pricePerCoin,
|
||||||
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
totalBaseCurrencyAmount: transaction.totalBaseCurrencyAmount,
|
||||||
timestamp: transaction.timestamp,
|
timestamp: transaction.timestamp,
|
||||||
|
recipientUserId: transaction.recipientUserId,
|
||||||
|
senderUserId: transaction.senderUserId,
|
||||||
coin: {
|
coin: {
|
||||||
id: coin.id,
|
id: coin.id,
|
||||||
name: coin.name,
|
name: coin.name,
|
||||||
symbol: coin.symbol,
|
symbol: coin.symbol,
|
||||||
icon: coin.icon
|
icon: coin.icon
|
||||||
|
},
|
||||||
|
recipientUser: {
|
||||||
|
id: recipientUser.id,
|
||||||
|
username: recipientUser.username
|
||||||
|
},
|
||||||
|
senderUser: {
|
||||||
|
id: senderUser.id,
|
||||||
|
username: senderUser.username
|
||||||
}
|
}
|
||||||
})
|
}).from(transaction)
|
||||||
.from(transaction)
|
.innerJoin(coin, eq(transaction.coinId, coin.id))
|
||||||
.leftJoin(coin, eq(transaction.coinId, coin.id))
|
.leftJoin(recipientUser, eq(transaction.recipientUserId, recipientUser.id))
|
||||||
|
.leftJoin(senderUser, eq(transaction.senderUserId, senderUser.id))
|
||||||
.where(whereConditions)
|
.where(whereConditions)
|
||||||
.orderBy(orderBy)
|
.orderBy(orderBy)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset((page - 1) * limit);
|
.offset((page - 1) * limit);
|
||||||
|
|
||||||
const formattedTransactions = transactions.map(tx => ({
|
const formattedTransactions = transactions.map(tx => {
|
||||||
...tx,
|
const isTransfer = tx.type.startsWith('TRANSFER_');
|
||||||
quantity: Number(tx.quantity),
|
const isIncoming = tx.type === 'TRANSFER_IN';
|
||||||
pricePerCoin: Number(tx.pricePerCoin),
|
const isCoinTransfer = isTransfer && Number(tx.quantity) > 0;
|
||||||
totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount)
|
|
||||||
}));
|
let actualSenderUsername = null;
|
||||||
|
let actualRecipientUsername = null;
|
||||||
|
|
||||||
|
if (isTransfer) {
|
||||||
|
actualSenderUsername = tx.senderUser?.username;
|
||||||
|
actualRecipientUsername = tx.recipientUser?.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...tx,
|
||||||
|
quantity: Number(tx.quantity),
|
||||||
|
pricePerCoin: Number(tx.pricePerCoin),
|
||||||
|
totalBaseCurrencyAmount: Number(tx.totalBaseCurrencyAmount),
|
||||||
|
isTransfer,
|
||||||
|
isIncoming,
|
||||||
|
isCoinTransfer,
|
||||||
|
recipient: actualRecipientUsername,
|
||||||
|
sender: actualSenderUsername,
|
||||||
|
transferInfo: isTransfer ? {
|
||||||
|
isTransfer: true,
|
||||||
|
isIncoming,
|
||||||
|
isCoinTransfer,
|
||||||
|
otherUser: isIncoming ?
|
||||||
|
(tx.senderUser ? { id: tx.senderUser.id, username: actualSenderUsername } : null) :
|
||||||
|
(tx.recipientUser ? { id: tx.recipientUser.id, username: actualRecipientUsername } : null)
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
transactions: formattedTransactions,
|
transactions: formattedTransactions,
|
||||||
|
|
|
||||||
259
website/src/routes/api/transfer/+server.ts
Normal file
259
website/src/routes/api/transfer/+server.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
import { auth } from '$lib/auth';
|
||||||
|
import { error, json } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { user, userPortfolio, coin, transaction } from '$lib/server/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
interface TransferRequest {
|
||||||
|
recipientUsername: string;
|
||||||
|
type: 'CASH' | 'COIN';
|
||||||
|
amount: number;
|
||||||
|
coinSymbol?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
throw error(401, 'Not authenticated');
|
||||||
|
} try {
|
||||||
|
const { recipientUsername, type, amount, coinSymbol }: TransferRequest = await request.json();
|
||||||
|
|
||||||
|
if (!recipientUsername || !type || !amount || typeof amount !== 'number' || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
throw error(400, 'Invalid transfer parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > Number.MAX_SAFE_INTEGER) {
|
||||||
|
throw error(400, 'Transfer amount too large');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'COIN' && !coinSymbol) {
|
||||||
|
throw error(400, 'Coin symbol required for coin transfers');
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderId = Number(session.user.id);
|
||||||
|
|
||||||
|
return await db.transaction(async (tx) => {
|
||||||
|
const [senderData] = await tx
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
baseCurrencyBalance: user.baseCurrencyBalance
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.id, senderId))
|
||||||
|
.for('update')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!senderData) {
|
||||||
|
throw error(404, 'Sender not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipientData] = await tx
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
baseCurrencyBalance: user.baseCurrencyBalance
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.where(eq(user.username, recipientUsername))
|
||||||
|
.for('update')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!recipientData) {
|
||||||
|
throw error(404, 'Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderData.id === recipientData.id) {
|
||||||
|
throw error(400, 'Cannot transfer to yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'CASH') {
|
||||||
|
const senderBalance = Number(senderData.baseCurrencyBalance);
|
||||||
|
if (senderBalance < amount) {
|
||||||
|
throw error(400, `Insufficient funds. You have $${senderBalance.toFixed(2)} but trying to send $${amount.toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientBalance = Number(recipientData.baseCurrencyBalance);
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: (senderBalance - amount).toFixed(8),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, senderId));
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(user)
|
||||||
|
.set({
|
||||||
|
baseCurrencyBalance: (recipientBalance + amount).toFixed(8),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(user.id, recipientData.id));
|
||||||
|
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
userId: senderId,
|
||||||
|
coinId: 1,
|
||||||
|
type: 'TRANSFER_OUT',
|
||||||
|
quantity: '0',
|
||||||
|
pricePerCoin: '1',
|
||||||
|
totalBaseCurrencyAmount: amount.toString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
senderUserId: senderId,
|
||||||
|
recipientUserId: recipientData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
userId: recipientData.id,
|
||||||
|
coinId: 1,
|
||||||
|
type: 'TRANSFER_IN',
|
||||||
|
quantity: '0',
|
||||||
|
pricePerCoin: '1',
|
||||||
|
totalBaseCurrencyAmount: amount.toString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
senderUserId: senderId,
|
||||||
|
recipientUserId: recipientData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
type: 'CASH',
|
||||||
|
amount,
|
||||||
|
recipient: recipientData.username,
|
||||||
|
newBalance: senderBalance - amount
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
const normalizedSymbol = coinSymbol!.toUpperCase();
|
||||||
|
|
||||||
|
const [coinData] = await tx
|
||||||
|
.select({ id: coin.id, symbol: coin.symbol, name: coin.name, currentPrice: coin.currentPrice })
|
||||||
|
.from(coin)
|
||||||
|
.where(eq(coin.symbol, normalizedSymbol))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!coinData) {
|
||||||
|
throw error(404, 'Coin not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [senderHolding] = await tx
|
||||||
|
.select({
|
||||||
|
quantity: userPortfolio.quantity
|
||||||
|
})
|
||||||
|
.from(userPortfolio)
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, senderId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
))
|
||||||
|
.for('update')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!senderHolding || Number(senderHolding.quantity) < amount) {
|
||||||
|
const availableAmount = senderHolding ? Number(senderHolding.quantity) : 0;
|
||||||
|
throw error(400, `Insufficient ${coinData.symbol}. You have ${availableAmount.toFixed(6)} but trying to send ${amount.toFixed(6)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [recipientHolding] = await tx
|
||||||
|
.select({ quantity: userPortfolio.quantity })
|
||||||
|
.from(userPortfolio)
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, recipientData.id),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
))
|
||||||
|
.for('update')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const coinPrice = Number(coinData.currentPrice) || 0;
|
||||||
|
const totalValue = amount * coinPrice;
|
||||||
|
|
||||||
|
const newSenderQuantity = Number(senderHolding.quantity) - amount;
|
||||||
|
if (newSenderQuantity > 0.000001) {
|
||||||
|
await tx
|
||||||
|
.update(userPortfolio)
|
||||||
|
.set({
|
||||||
|
quantity: newSenderQuantity.toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, senderId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
await tx
|
||||||
|
.delete(userPortfolio)
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, senderId),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recipientHolding) {
|
||||||
|
const newRecipientQuantity = Number(recipientHolding.quantity) + amount;
|
||||||
|
await tx
|
||||||
|
.update(userPortfolio)
|
||||||
|
.set({
|
||||||
|
quantity: newRecipientQuantity.toString(),
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(and(
|
||||||
|
eq(userPortfolio.userId, recipientData.id),
|
||||||
|
eq(userPortfolio.coinId, coinData.id)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
await tx
|
||||||
|
.insert(userPortfolio)
|
||||||
|
.values({
|
||||||
|
userId: recipientData.id,
|
||||||
|
coinId: coinData.id,
|
||||||
|
quantity: amount.toString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
userId: senderId,
|
||||||
|
coinId: coinData.id,
|
||||||
|
type: 'TRANSFER_OUT',
|
||||||
|
quantity: amount.toString(),
|
||||||
|
pricePerCoin: coinPrice.toString(),
|
||||||
|
totalBaseCurrencyAmount: totalValue.toString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
senderUserId: senderId,
|
||||||
|
recipientUserId: recipientData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.insert(transaction).values({
|
||||||
|
userId: recipientData.id,
|
||||||
|
coinId: coinData.id,
|
||||||
|
type: 'TRANSFER_IN',
|
||||||
|
quantity: amount.toString(),
|
||||||
|
pricePerCoin: coinPrice.toString(),
|
||||||
|
totalBaseCurrencyAmount: totalValue.toString(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
senderUserId: senderId,
|
||||||
|
recipientUserId: recipientData.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: true,
|
||||||
|
type: 'COIN',
|
||||||
|
amount,
|
||||||
|
coinSymbol: coinData.symbol,
|
||||||
|
coinName: coinData.name,
|
||||||
|
recipient: recipientData.username,
|
||||||
|
newQuantity: newSenderQuantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Transfer error:', e);
|
||||||
|
if (e && typeof e === 'object' && 'status' in e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return json({ error: 'Transfer failed' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
import { auth } from '$lib/auth';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export async function load({ request }) {
|
|
||||||
const session = await auth.api.getSession({
|
|
||||||
headers: request.headers
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session?.user) {
|
|
||||||
throw redirect(302, '/');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: session.user
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
|
||||||
import DataTable from '$lib/components/self/DataTable.svelte';
|
import DataTable from '$lib/components/self/DataTable.svelte';
|
||||||
import PortfolioSkeleton from '$lib/components/self/skeletons/PortfolioSkeleton.svelte';
|
import PortfolioSkeleton from '$lib/components/self/skeletons/PortfolioSkeleton.svelte';
|
||||||
import SEO from '$lib/components/self/SEO.svelte';
|
import SEO from '$lib/components/self/SEO.svelte';
|
||||||
import { getPublicUrl, formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
|
import { formatPrice, formatValue, formatQuantity, formatDate } from '$lib/utils';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { TrendingUp, DollarSign, Wallet, TrendingDown, Clock, Receipt } from 'lucide-svelte';
|
import { TrendingUp, DollarSign, Wallet, Receipt, Send } from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
|
import SendMoneyModal from '$lib/components/self/SendMoneyModal.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
// TODO: add type definitions
|
||||||
|
|
||||||
let portfolioData = $state<any>(null);
|
let portfolioData = $state<any>(null);
|
||||||
let transactions = $state<any[]>([]);
|
let transactions = $state<any[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
let sendMoneyModalOpen = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
|
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
|
||||||
|
|
@ -125,51 +125,100 @@
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
class: 'w-[15%] min-w-[60px] md:w-[10%]',
|
class: 'w-[12%] min-w-[60px] md:w-[8%]',
|
||||||
render: (value: any) => ({
|
render: (value: any, row: any) => {
|
||||||
component: 'badge',
|
if (row.isTransfer) {
|
||||||
variant: value === 'BUY' ? 'success' : 'destructive',
|
return {
|
||||||
text: value === 'BUY' ? 'Buy' : 'Sell',
|
component: 'badge',
|
||||||
class: 'text-xs'
|
variant: 'default',
|
||||||
})
|
text: row.isIncoming ? 'Received' : 'Sent',
|
||||||
|
class: 'text-xs'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'badge',
|
||||||
|
variant: value === 'BUY' ? 'success' : 'destructive',
|
||||||
|
text: value === 'BUY' ? 'Buy' : 'Sell',
|
||||||
|
class: 'text-xs'
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'coin',
|
key: 'coin',
|
||||||
label: 'Coin',
|
label: 'Coin',
|
||||||
class: 'w-[30%] min-w-[100px] md:w-[20%]',
|
class: 'w-[20%] min-w-[100px] md:w-[12%]',
|
||||||
|
render: (value: any, row: any) => {
|
||||||
|
if (row.isTransfer) {
|
||||||
|
if (row.isCoinTransfer && row.coin) {
|
||||||
|
return {
|
||||||
|
component: 'coin',
|
||||||
|
icon: row.coin.icon,
|
||||||
|
symbol: row.coin.symbol,
|
||||||
|
name: `*${row.coin.symbol}`,
|
||||||
|
size: 4
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { component: 'text', text: '-' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'coin',
|
||||||
|
icon: row.coin.icon,
|
||||||
|
symbol: row.coin.symbol,
|
||||||
|
name: `*${row.coin.symbol}`,
|
||||||
|
size: 4
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sender',
|
||||||
|
label: 'Sender',
|
||||||
|
class: 'w-[12%] min-w-[70px] md:w-[10%]',
|
||||||
render: (value: any, row: any) => ({
|
render: (value: any, row: any) => ({
|
||||||
component: 'coin',
|
component: 'text',
|
||||||
icon: row.coin.icon,
|
text: row.isTransfer ? row.sender || 'Unknown' : '-',
|
||||||
symbol: row.coin.symbol,
|
class:
|
||||||
name: `*${row.coin.symbol}`,
|
row.isTransfer && row.sender && row.sender !== 'Unknown'
|
||||||
size: 4
|
? 'font-medium'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recipient',
|
||||||
|
label: 'Receiver',
|
||||||
|
class: 'w-[12%] min-w-[70px] md:w-[10%]',
|
||||||
|
render: (value: any, row: any) => ({
|
||||||
|
component: 'text',
|
||||||
|
text: row.isTransfer ? row.recipient || 'Unknown' : '-',
|
||||||
|
class:
|
||||||
|
row.isTransfer && row.recipient && row.recipient !== 'Unknown'
|
||||||
|
? 'font-medium'
|
||||||
|
: 'text-muted-foreground'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'quantity',
|
key: 'quantity',
|
||||||
label: 'Quantity',
|
label: 'Quantity',
|
||||||
class: 'w-[20%] min-w-[80px] md:w-[15%] font-mono text-sm',
|
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm',
|
||||||
render: (value: any) => formatQuantity(value)
|
render: (value: any, row: any) =>
|
||||||
},
|
row.isTransfer && value === 0 ? '-' : formatQuantity(value)
|
||||||
{
|
|
||||||
key: 'pricePerCoin',
|
|
||||||
label: 'Price',
|
|
||||||
class: 'w-[15%] min-w-[70px] md:w-[15%] font-mono text-sm',
|
|
||||||
render: (value: any) => `$${formatPrice(value)}`
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'totalBaseCurrencyAmount',
|
key: 'totalBaseCurrencyAmount',
|
||||||
label: 'Total',
|
label: 'Amount',
|
||||||
class: 'w-[20%] min-w-[70px] md:w-[15%] font-mono text-sm font-medium',
|
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm font-medium',
|
||||||
render: (value: any) => formatValue(value)
|
render: (value: any) => formatValue(value)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
label: 'Date',
|
label: 'Date',
|
||||||
class: 'hidden md:table-cell md:w-[25%] text-muted-foreground text-sm',
|
class: 'hidden md:table-cell md:w-[18%] text-muted-foreground text-sm',
|
||||||
render: (value: any) => formatDate(value)
|
render: (value: any) => formatDate(value)
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
async function handleTransferSuccess() {
|
||||||
|
await Promise.all([fetchPortfolioData(), fetchRecentTransactions()]);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SEO
|
<SEO
|
||||||
|
|
@ -179,14 +228,9 @@
|
||||||
keywords="virtual portfolio management, crypto holdings game, trading performance simulator, investment tracking game"
|
keywords="virtual portfolio management, crypto holdings game, trading performance simulator, investment tracking game"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="container mx-auto max-w-7xl p-6">
|
<SendMoneyModal bind:open={sendMoneyModalOpen} onSuccess={handleTransferSuccess} />
|
||||||
<header class="mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold">Portfolio</h1>
|
|
||||||
<p class="text-muted-foreground">View your holdings and portfolio performance</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-7xl p-6">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<PortfolioSkeleton />
|
<PortfolioSkeleton />
|
||||||
{:else if error}
|
{:else if error}
|
||||||
|
|
@ -196,122 +240,148 @@
|
||||||
<Button onclick={retryFetch}>Try Again</Button>
|
<Button onclick={retryFetch}>Try Again</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else if !$USER_DATA}
|
||||||
<!-- Portfolio Summary Cards -->
|
<div class="flex h-96 items-center justify-center">
|
||||||
<div class="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div class="text-center">
|
||||||
<!-- Total Portfolio Value -->
|
<div class="text-muted-foreground mb-4 text-xl">
|
||||||
<Card.Root class="text-success gap-1">
|
You need to be logged in to view your portfolio
|
||||||
<Card.Header>
|
</div>
|
||||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
<Button onclick={() => goto('/login')}>Log In</Button>
|
||||||
<Wallet class="h-4 w-4" />
|
</div>
|
||||||
Total
|
|
||||||
</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content>
|
|
||||||
<p class="text-3xl font-bold">{formatValue(totalPortfolioValue)}</p>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<!-- Base Currency Balance -->
|
|
||||||
<Card.Root class="gap-1">
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<DollarSign class="h-4 w-4" />
|
|
||||||
Cash Balance
|
|
||||||
</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content>
|
|
||||||
<p class="text-3xl font-bold">
|
|
||||||
{formatValue(portfolioData.baseCurrencyBalance)}
|
|
||||||
</p>
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
{totalPortfolioValue > 0
|
|
||||||
? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio`
|
|
||||||
: '100% of portfolio'}
|
|
||||||
</p>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
|
|
||||||
<!-- Coin Holdings Value -->
|
|
||||||
<Card.Root class="gap-1">
|
|
||||||
<Card.Header>
|
|
||||||
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
|
||||||
<TrendingUp class="h-4 w-4" />
|
|
||||||
Coin Holdings
|
|
||||||
</Card.Title>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content>
|
|
||||||
<p class="text-3xl font-bold">{formatValue(portfolioData.totalCoinValue)}</p>
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
{portfolioData.coinHoldings.length} positions
|
|
||||||
</p>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Portfolio Overview -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold">Portfolio</h1>
|
||||||
|
<p class="text-muted-foreground">Manage your investments and transactions</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button onclick={() => (sendMoneyModalOpen = true)}>
|
||||||
|
<Send class="h-4 w-4" />
|
||||||
|
Send Money
|
||||||
|
</Button>
|
||||||
|
<!-- ...existing buttons... -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if !hasHoldings}
|
<!-- Portfolio Summary Cards -->
|
||||||
<!-- Empty State -->
|
<div class="mb-8 grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
<Card.Root>
|
<!-- Total Portfolio Value -->
|
||||||
<Card.Content class="py-16 text-center">
|
<Card.Root class="text-success gap-1">
|
||||||
<div
|
<Card.Header>
|
||||||
class="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||||
>
|
<Wallet class="h-4 w-4" />
|
||||||
<Wallet class="text-muted-foreground h-8 w-8" />
|
Total
|
||||||
</div>
|
</Card.Title>
|
||||||
<h3 class="mb-2 text-lg font-semibold">No coin holdings</h3>
|
</Card.Header>
|
||||||
<p class="text-muted-foreground mb-6">
|
<Card.Content>
|
||||||
You haven't invested in any coins yet. Start by buying existing coins.
|
<p class="text-3xl font-bold">{formatValue(totalPortfolioValue)}</p>
|
||||||
</p>
|
</Card.Content>
|
||||||
<div class="flex justify-center">
|
</Card.Root>
|
||||||
<Button variant="outline" onclick={() => goto('/')}>Browse Coins</Button>
|
|
||||||
</div>
|
<!-- Base Currency Balance -->
|
||||||
</Card.Content>
|
<Card.Root class="gap-1">
|
||||||
</Card.Root>
|
<Card.Header>
|
||||||
{:else}
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||||
<!-- Holdings Table -->
|
<DollarSign class="h-4 w-4" />
|
||||||
<Card.Root>
|
Cash Balance
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="text-3xl font-bold">
|
||||||
|
{formatValue(portfolioData.baseCurrencyBalance)}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{totalPortfolioValue > 0
|
||||||
|
? `${((portfolioData.baseCurrencyBalance / totalPortfolioValue) * 100).toFixed(1)}% of portfolio`
|
||||||
|
: '100% of portfolio'}
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<!-- Coin Holdings Value -->
|
||||||
|
<Card.Root class="gap-1">
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2 text-sm font-medium">
|
||||||
|
<TrendingUp class="h-4 w-4" />
|
||||||
|
Coin Holdings
|
||||||
|
</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="text-3xl font-bold">{formatValue(portfolioData.totalCoinValue)}</p>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{portfolioData.coinHoldings.length} positions
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !hasHoldings}
|
||||||
|
<!-- Empty State -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="py-16 text-center">
|
||||||
|
<div
|
||||||
|
class="bg-muted mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
|
||||||
|
>
|
||||||
|
<Wallet class="text-muted-foreground h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-2 text-lg font-semibold">No coin holdings</h3>
|
||||||
|
<p class="text-muted-foreground mb-6">
|
||||||
|
You haven't invested in any coins yet. Start by buying existing coins.
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<Button variant="outline" onclick={() => goto('/')}>Browse Coins</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{:else}
|
||||||
|
<!-- Holdings Table -->
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Your Holdings</Card.Title>
|
||||||
|
<Card.Description>Current positions in your portfolio</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<DataTable
|
||||||
|
columns={holdingsColumns}
|
||||||
|
data={portfolioData.coinHoldings}
|
||||||
|
onRowClick={(holding) => goto(`/coin/${holding.symbol}`)}
|
||||||
|
/>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Recent Transactions -->
|
||||||
|
<Card.Root class="mt-8">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Your Holdings</Card.Title>
|
<div class="flex items-center justify-between">
|
||||||
<Card.Description>Current positions in your portfolio</Card.Description>
|
<div>
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Receipt class="h-5 w-5" />
|
||||||
|
Recent Transactions
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>Your latest trading activity</Card.Description>
|
||||||
|
</div>
|
||||||
|
{#if hasTransactions}
|
||||||
|
<Button variant="outline" size="sm" onclick={() => goto('/transactions')}>
|
||||||
|
View All
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={holdingsColumns}
|
columns={transactionsColumns}
|
||||||
data={portfolioData.coinHoldings}
|
data={transactions}
|
||||||
onRowClick={(holding) => goto(`/coin/${holding.symbol}`)}
|
onRowClick={(tx) => !tx.isTransfer && goto(`/coin/${tx.coin.symbol}`)}
|
||||||
|
emptyIcon={Receipt}
|
||||||
|
emptyTitle="No transactions yet"
|
||||||
|
emptyDescription="You haven't made any trades yet. Start by buying or selling coins."
|
||||||
/>
|
/>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
{/if}
|
</div>
|
||||||
|
|
||||||
<!-- Recent Transactions -->
|
|
||||||
<Card.Root class="mt-8">
|
|
||||||
<Card.Header>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<Card.Title class="flex items-center gap-2">
|
|
||||||
<Receipt class="h-5 w-5" />
|
|
||||||
Recent Transactions
|
|
||||||
</Card.Title>
|
|
||||||
<Card.Description>Your latest trading activity</Card.Description>
|
|
||||||
</div>
|
|
||||||
{#if hasTransactions}
|
|
||||||
<Button variant="outline" size="sm" onclick={() => goto('/transactions')}>
|
|
||||||
View All
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Content>
|
|
||||||
<DataTable
|
|
||||||
columns={transactionsColumns}
|
|
||||||
data={transactions}
|
|
||||||
onRowClick={(tx) => goto(`/coin/${tx.coin.symbol}`)}
|
|
||||||
emptyIcon={Receipt}
|
|
||||||
emptyTitle="No transactions yet"
|
|
||||||
emptyDescription="You haven't made any trades yet. Start by buying or selling coins."
|
|
||||||
/>
|
|
||||||
</Card.Content>
|
|
||||||
</Card.Root>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,9 @@
|
||||||
const typeFilterOptions = [
|
const typeFilterOptions = [
|
||||||
{ value: 'all', label: 'All transactions' },
|
{ value: 'all', label: 'All transactions' },
|
||||||
{ value: 'BUY', label: 'Buys only' },
|
{ value: 'BUY', label: 'Buys only' },
|
||||||
{ value: 'SELL', label: 'Sells only' }
|
{ value: 'SELL', label: 'Sells only' },
|
||||||
|
{ value: 'TRANSFER_IN', label: 'Received transfers' },
|
||||||
|
{ value: 'TRANSFER_OUT', label: 'Sent transfers' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const sortOrderOptions = [
|
const sortOrderOptions = [
|
||||||
|
|
@ -50,7 +52,7 @@
|
||||||
];
|
];
|
||||||
|
|
||||||
const debouncedSearch = debounce(performSearch, 300);
|
const debouncedSearch = debounce(performSearch, 300);
|
||||||
let previousSearchQueryForEffect = $state(searchQuery);
|
let previousSearchQueryForEffect = $state('');
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
fetchTransactions();
|
fetchTransactions();
|
||||||
|
|
@ -211,43 +213,110 @@
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
class: 'w-[10%] min-w-[60px]',
|
class: 'w-[10%] min-w-[80px]',
|
||||||
render: (value: any) => ({
|
render: (value: any, row: any) => {
|
||||||
component: 'badge',
|
if (row.isTransfer) {
|
||||||
variant: value === 'BUY' ? 'success' : 'destructive',
|
return {
|
||||||
text: value === 'BUY' ? 'Buy' : 'Sell',
|
component: 'badge',
|
||||||
class: 'text-xs'
|
variant: 'default',
|
||||||
})
|
text: row.isIncoming ? 'Received' : 'Sent',
|
||||||
|
class: 'text-xs'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'badge',
|
||||||
|
variant: value === 'BUY' ? 'success' : 'destructive',
|
||||||
|
text: value === 'BUY' ? 'Buy' : 'Sell',
|
||||||
|
class: 'text-xs'
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'coin',
|
key: 'coin',
|
||||||
label: 'Coin',
|
label: 'Asset',
|
||||||
class: 'w-[20%] min-w-[120px]',
|
class: 'w-[20%] min-w-[120px]',
|
||||||
|
render: (value: any, row: any) => {
|
||||||
|
if (row.isTransfer) {
|
||||||
|
if (row.isCoinTransfer && row.coin) {
|
||||||
|
return {
|
||||||
|
component: 'coin',
|
||||||
|
icon: row.coin.icon,
|
||||||
|
symbol: row.coin.symbol,
|
||||||
|
name: `*${row.coin.symbol}`,
|
||||||
|
size: 6
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'text',
|
||||||
|
text: 'Cash ($)',
|
||||||
|
class: 'font-medium'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'coin',
|
||||||
|
icon: row.coin.icon,
|
||||||
|
symbol: row.coin.symbol,
|
||||||
|
name: `*${row.coin.symbol}`,
|
||||||
|
size: 6
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sender',
|
||||||
|
label: 'Sender',
|
||||||
|
class: 'w-[12%] min-w-[80px]',
|
||||||
render: (value: any, row: any) => ({
|
render: (value: any, row: any) => ({
|
||||||
component: 'coin',
|
component: 'text',
|
||||||
icon: row.coin.icon,
|
text: row.isTransfer ? row.sender || 'Unknown' : '-',
|
||||||
symbol: row.coin.symbol,
|
class:
|
||||||
name: `*${row.coin.symbol}`,
|
row.isTransfer && row.sender && row.sender !== 'Unknown'
|
||||||
size: 6
|
? 'font-medium'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recipient',
|
||||||
|
label: 'Receiver',
|
||||||
|
class: 'w-[12%] min-w-[80px]',
|
||||||
|
render: (value: any, row: any) => ({
|
||||||
|
component: 'text',
|
||||||
|
text: row.isTransfer ? row.recipient || 'Unknown' : '-',
|
||||||
|
class:
|
||||||
|
row.isTransfer && row.recipient && row.recipient !== 'Unknown'
|
||||||
|
? 'font-medium'
|
||||||
|
: 'text-muted-foreground'
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'quantity',
|
key: 'quantity',
|
||||||
label: 'Quantity',
|
label: 'Quantity',
|
||||||
class: 'w-[15%] min-w-[100px] font-mono',
|
class: 'w-[15%] min-w-[100px] font-mono',
|
||||||
render: (value: any) => formatQuantity(value)
|
render: (value: any, row: any) => {
|
||||||
|
if (row.isTransfer && value === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return formatQuantity(value);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pricePerCoin',
|
key: 'pricePerCoin',
|
||||||
label: 'Price',
|
label: 'Price',
|
||||||
class: 'w-[15%] min-w-[80px] font-mono',
|
class: 'w-[15%] min-w-[80px] font-mono',
|
||||||
render: (value: any) => `$${formatPrice(value)}`
|
render: (value: any, row: any) => {
|
||||||
|
if (row.isTransfer || value === 0) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
return `$${formatPrice(value)}`;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'totalBaseCurrencyAmount',
|
key: 'totalBaseCurrencyAmount',
|
||||||
label: 'Total',
|
label: 'Total',
|
||||||
class: 'w-[15%] min-w-[80px] font-mono font-medium',
|
class: 'w-[15%] min-w-[80px] font-mono font-medium',
|
||||||
render: (value: any) => formatValue(value)
|
render: (value: any, row: any) => {
|
||||||
|
const prefix = row.type === 'TRANSFER_IN' || row.type === 'BUY' ? '+' : '-';
|
||||||
|
return `${prefix}${formatValue(value)}`;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
|
|
@ -413,7 +482,7 @@
|
||||||
<Receipt class="h-5 w-5" />
|
<Receipt class="h-5 w-5" />
|
||||||
History
|
History
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
<Card.Description>Complete record of your trading activity</Card.Description>
|
<Card.Description>Complete record of your trading activity and transfers</Card.Description>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|
@ -426,12 +495,16 @@
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={transactionsColumns}
|
columns={transactionsColumns}
|
||||||
data={transactions}
|
data={transactions}
|
||||||
onRowClick={(tx) => goto(`/coin/${tx.coin.symbol}`)}
|
onRowClick={(tx) => {
|
||||||
|
if (tx.coin) {
|
||||||
|
goto(`/coin/${tx.coin.symbol}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
emptyIcon={Receipt}
|
emptyIcon={Receipt}
|
||||||
emptyTitle="No transactions found"
|
emptyTitle="No transactions found"
|
||||||
emptyDescription={hasActiveFilters
|
emptyDescription={hasActiveFilters
|
||||||
? 'No transactions match your current filters. Try adjusting your search criteria.'
|
? 'No transactions match your current filters. Try adjusting your search criteria.'
|
||||||
: "You haven't made any trades yet. Start by buying or selling coins."}
|
: "You haven't made any trades or transfers yet. Start by buying coins or sending money to other users."}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,34 @@
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import type { UserProfileData } from '$lib/types/user-profile';
|
import type { UserProfileData } from '$lib/types/user-profile';
|
||||||
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const username = data.username;
|
const username = data.username;
|
||||||
|
|
||||||
let profileData = $state<UserProfileData | null>(null);
|
let profileData = $state<UserProfileData | null>(null);
|
||||||
|
let recentTransactions = $state<any[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
|
let isOwnProfile = $derived(
|
||||||
|
$USER_DATA && profileData?.profile && $USER_DATA.username === profileData.profile.username
|
||||||
|
);
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await fetchProfileData();
|
await fetchProfileData();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (isOwnProfile && profileData) {
|
||||||
|
fetchTransactions();
|
||||||
|
}
|
||||||
|
});
|
||||||
async function fetchProfileData() {
|
async function fetchProfileData() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/user/${username}`);
|
const response = await fetch(`/api/user/${username}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
profileData = await response.json();
|
profileData = await response.json();
|
||||||
|
|
||||||
|
recentTransactions = profileData?.recentTransactions || [];
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to load profile data');
|
toast.error('Failed to load profile data');
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +60,20 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchTransactions() {
|
||||||
|
if (!isOwnProfile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/transactions?limit=10');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
recentTransactions = data.transactions || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch transactions:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let memberSince = $derived(
|
let memberSince = $derived(
|
||||||
profileData?.profile
|
profileData?.profile
|
||||||
? new Date(profileData.profile.createdAt).toLocaleDateString('en-US', {
|
? new Date(profileData.profile.createdAt).toLocaleDateString('en-US', {
|
||||||
|
|
@ -144,52 +170,158 @@
|
||||||
render: (value: any) => formatDate(value)
|
render: (value: any) => formatDate(value)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const transactionsColumns = [
|
const transactionsColumns = [
|
||||||
{
|
{
|
||||||
key: 'type',
|
key: 'type',
|
||||||
label: 'Type',
|
label: 'Type',
|
||||||
class: 'pl-6',
|
class: 'w-[12%] min-w-[60px] md:w-[8%] pl-6',
|
||||||
render: (value: any) => ({
|
render: (value: any, row: any) => {
|
||||||
component: 'badge',
|
// Handle transfer types (TRANSFER_IN, TRANSFER_OUT) from user profile API
|
||||||
variant: value === 'BUY' ? 'success' : 'destructive',
|
if (value === 'TRANSFER_IN' || value === 'TRANSFER_OUT') {
|
||||||
text: value
|
return {
|
||||||
})
|
component: 'badge',
|
||||||
|
variant: 'default',
|
||||||
|
text: value === 'TRANSFER_IN' ? 'Received' : 'Sent',
|
||||||
|
class: 'text-xs'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Handle isTransfer format from transactions API
|
||||||
|
if (row.isTransfer) {
|
||||||
|
return {
|
||||||
|
component: 'badge',
|
||||||
|
variant: 'default',
|
||||||
|
text: row.isIncoming ? 'Received' : 'Sent',
|
||||||
|
class: 'text-xs'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'badge',
|
||||||
|
variant: value === 'BUY' ? 'success' : 'destructive',
|
||||||
|
text: value === 'BUY' ? 'Buy' : 'Sell',
|
||||||
|
class: 'text-xs'
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'coin',
|
key: 'coin',
|
||||||
label: 'Coin',
|
label: 'Coin',
|
||||||
class: 'font-medium',
|
class: 'w-[20%] min-w-[100px] md:w-[12%]',
|
||||||
render: (value: any, row: any) => ({
|
render: (value: any, row: any) => {
|
||||||
component: 'coin',
|
// Handle transfer format from transactions API
|
||||||
icon: row.coinIcon,
|
if (row.isTransfer) {
|
||||||
symbol: row.coinSymbol,
|
if (row.isCoinTransfer && row.coin) {
|
||||||
name: row.coinName,
|
return {
|
||||||
size: 6
|
component: 'coin',
|
||||||
})
|
icon: row.coin.icon,
|
||||||
|
symbol: row.coin.symbol,
|
||||||
|
name: `*${row.coin.symbol}`,
|
||||||
|
size: 4
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { component: 'text', text: '-' };
|
||||||
|
}
|
||||||
|
// Handle transfer types from user profile API
|
||||||
|
if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') {
|
||||||
|
if (row.coinSymbol && Number(row.quantity) > 0) {
|
||||||
|
return {
|
||||||
|
component: 'coin',
|
||||||
|
icon: row.coinIcon,
|
||||||
|
symbol: row.coinSymbol,
|
||||||
|
name: `*${row.coinSymbol}`,
|
||||||
|
size: 4
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { component: 'text', text: '-' };
|
||||||
|
}
|
||||||
|
// Handle regular transactions from both APIs
|
||||||
|
return {
|
||||||
|
component: 'coin',
|
||||||
|
icon: row.coinIcon || row.coin?.icon,
|
||||||
|
symbol: row.coinSymbol || row.coin?.symbol,
|
||||||
|
name: `*${row.coinSymbol || row.coin?.symbol}`,
|
||||||
|
size: 4
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sender',
|
||||||
|
label: 'Sender',
|
||||||
|
class: 'w-[12%] min-w-[70px] md:w-[10%]',
|
||||||
|
render: (value: any, row: any) => {
|
||||||
|
// Handle transactions API format
|
||||||
|
if (row.isTransfer) {
|
||||||
|
return {
|
||||||
|
component: 'text',
|
||||||
|
text: row.sender || 'Unknown',
|
||||||
|
class: row.sender && row.sender !== 'Unknown' ? 'font-medium' : 'text-muted-foreground'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Handle user profile API format (no sender/recipient data available)
|
||||||
|
if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') {
|
||||||
|
return {
|
||||||
|
component: 'text',
|
||||||
|
text: 'Unknown',
|
||||||
|
class: 'text-muted-foreground'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'text',
|
||||||
|
text: '-',
|
||||||
|
class: 'text-muted-foreground'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recipient',
|
||||||
|
label: 'Receiver',
|
||||||
|
class: 'w-[12%] min-w-[70px] md:w-[10%]',
|
||||||
|
render: (value: any, row: any) => {
|
||||||
|
if (row.isTransfer) {
|
||||||
|
return {
|
||||||
|
component: 'text',
|
||||||
|
text: row.recipient || 'Unknown',
|
||||||
|
class:
|
||||||
|
row.recipient && row.recipient !== 'Unknown' ? 'font-medium' : 'text-muted-foreground'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') {
|
||||||
|
return {
|
||||||
|
component: 'text',
|
||||||
|
text: 'Unknown',
|
||||||
|
class: 'text-muted-foreground'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
component: 'text',
|
||||||
|
text: '-',
|
||||||
|
class: 'text-muted-foreground'
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'quantity',
|
key: 'quantity',
|
||||||
label: 'Quantity',
|
label: 'Quantity',
|
||||||
class: 'hidden font-mono sm:table-cell',
|
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm',
|
||||||
render: (value: any) => formatQuantity(parseFloat(value))
|
render: (value: any, row: any) => {
|
||||||
},
|
if (
|
||||||
{
|
(row.isTransfer && value === 0) ||
|
||||||
key: 'pricePerCoin',
|
((row.type === 'TRANSFER_IN' || row.type === 'TRANSFER_OUT') && value === 0)
|
||||||
label: 'Price',
|
) {
|
||||||
class: 'font-mono',
|
return '-';
|
||||||
render: (value: any) => `$${formatPrice(parseFloat(value))}`
|
}
|
||||||
|
return formatQuantity(parseFloat(value));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'totalBaseCurrencyAmount',
|
key: 'totalBaseCurrencyAmount',
|
||||||
label: 'Total',
|
label: 'Amount',
|
||||||
class: 'hidden font-mono font-medium md:table-cell',
|
class: 'w-[12%] min-w-[70px] md:w-[10%] font-mono text-sm font-medium',
|
||||||
render: (value: any) => formatValue(parseFloat(value))
|
render: (value: any) => formatValue(parseFloat(value))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
label: 'Date',
|
label: 'Date',
|
||||||
class: 'text-muted-foreground hidden text-sm lg:table-cell',
|
class: 'hidden md:table-cell md:w-[18%] text-muted-foreground text-sm',
|
||||||
render: (value: any) => formatDate(value)
|
render: (value: any) => formatDate(value)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -203,9 +335,7 @@
|
||||||
? `${profileData.profile.bio} - View ${profileData.profile.name}'s simulated trading activity and virtual portfolio in the Rugplay cryptocurrency simulation game.`
|
? `${profileData.profile.bio} - View ${profileData.profile.name}'s simulated trading activity and virtual portfolio in the Rugplay cryptocurrency simulation game.`
|
||||||
: `View @${username}'s profile and simulated trading activity in Rugplay - cryptocurrency trading simulation game platform.`}
|
: `View @${username}'s profile and simulated trading activity in Rugplay - cryptocurrency trading simulation game platform.`}
|
||||||
type="profile"
|
type="profile"
|
||||||
image={profileData?.profile?.image
|
image={profileData?.profile?.image ? getPublicUrl(profileData.profile.image) : '/rugplay.svg'}
|
||||||
? getPublicUrl(profileData.profile.image)
|
|
||||||
: '/rugplay.svg'}
|
|
||||||
imageAlt={profileData?.profile?.name
|
imageAlt={profileData?.profile?.name
|
||||||
? `${profileData.profile.name}'s profile picture`
|
? `${profileData.profile.name}'s profile picture`
|
||||||
: `@${username}'s profile`}
|
: `@${username}'s profile`}
|
||||||
|
|
@ -440,7 +570,7 @@
|
||||||
<Card.Content class="p-0">
|
<Card.Content class="p-0">
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={transactionsColumns}
|
columns={transactionsColumns}
|
||||||
data={profileData?.recentTransactions || []}
|
data={recentTransactions}
|
||||||
emptyIcon={Receipt}
|
emptyIcon={Receipt}
|
||||||
emptyTitle="No recent activity"
|
emptyTitle="No recent activity"
|
||||||
emptyDescription="This user hasn't made any trades yet."
|
emptyDescription="This user hasn't made any trades yet."
|
||||||
|
|
|
||||||
Reference in a new issue