Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 153 additions & 142 deletions src/components/common/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { ChevronDown, ChevronUp, ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useFormatXlm, useRelativeTime } from '@/hooks/formatting';

interface Transaction {
id: string;
Expand Down Expand Up @@ -69,17 +70,150 @@ const SAMPLE_TRANSACTIONS: Transaction[] = [
},
];

const formatTimestamp = (timestamp: number) => {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / (1000 * 60));
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const getTransactionIcon = (type: Transaction['type']) => {
switch (type) {
case 'buy':
return <ArrowUpRight className="size-4 text-emerald-400" />;
case 'sell':
return <ArrowDownRight className="size-4 text-rose-400" />;
default:
return <Minus className="size-4 text-white/40" />;
}
};

const getTransactionTypeLabel = (type: Transaction['type']) => {
switch (type) {
case 'buy':
return 'Buy';
case 'sell':
return 'Sell';
default:
return 'Unknown';
}
};

interface TransactionRowProps {
tx: Transaction;
isCompact: boolean;
isExpanded: boolean;
onToggleExpansion: (id: string) => void;
}

const TransactionRow: React.FC<TransactionRowProps> = ({
tx,
isCompact,
isExpanded,
onToggleExpansion,
}) => {
const relativeTime = useRelativeTime(tx.timestamp);
const totalAmount = useFormatXlm(tx.amount * tx.price, 4);

return (
<div
className={cn(
'group rounded-xl border border-white/10 bg-white/[0.02] transition-all duration-200 hover:border-white/20 hover:bg-white/[0.04]',
isCompact && !isExpanded && 'py-2',
(!isCompact || isExpanded) && 'p-4'
)}
>
<div className="flex items-center gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5">
{getTransactionIcon(tx.type)}
</div>

<div className="flex min-w-0 flex-1 items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-white">
{getTransactionTypeLabel(tx.type)}
</span>
<span className="text-white/40">•</span>
<span className="text-white/90">{tx.creator}</span>
</div>
{(!isCompact || isExpanded) && (
<div className="mt-1 flex items-center gap-3 text-xs text-white/50">
<span>{tx.amount} keys</span>
<span className="text-white/30">•</span>
<span>{tx.price} ETH</span>
<span className="text-white/30">•</span>
<span>{relativeTime}</span>
</div>
)}
</div>

{(!isCompact || isExpanded) && (
<div className="hidden shrink-0 items-center gap-4 text-right sm:flex">
<div className="text-sm">
<div className="font-semibold text-white">
{tx.type === 'buy' ? '+' : '-'}
{totalAmount} ETH
</div>
<div className="text-xs text-white/50">
{tx.txHash}
</div>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1">
<div className="size-1.5 rounded-full bg-emerald-400" />
<span className="text-xs font-semibold text-emerald-400">
{tx.status}
</span>
</div>
</div>
)}

{isCompact && !isExpanded && (
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<div className="text-sm font-semibold text-white">
{tx.type === 'buy' ? '+' : '-'}
{totalAmount} ETH
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onToggleExpansion(tx.id)}
className="h-8 w-8 rounded-lg p-0 text-white/50 hover:bg-white/10 hover:text-white"
>
<ChevronDown className="size-4" />
</Button>
</div>
)}

{isCompact && isExpanded && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleExpansion(tx.id)}
className="h-8 w-8 rounded-lg p-0 text-white/50 hover:bg-white/10 hover:text-white"
>
<ChevronUp className="size-4" />
</Button>
)}
</div>
</div>

if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
{isCompact && isExpanded && (
<div className="mt-3 flex items-center justify-between border-t border-white/10 pt-3 text-xs">
<div className="flex items-center gap-3 text-white/50">
<span>{tx.amount} keys</span>
<span className="text-white/30">•</span>
<span>{tx.price} ETH</span>
<span className="text-white/30">•</span>
<span>{relativeTime}</span>
<span className="text-white/30">•</span>
<span>{tx.txHash}</span>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1">
<div className="size-1.5 rounded-full bg-emerald-400" />
<span className="text-xs font-semibold text-emerald-400">
{tx.status}
</span>
</div>
</div>
)}
</div>
);
};

const TransactionHistory: React.FC = () => {
Expand Down Expand Up @@ -110,28 +244,6 @@ const TransactionHistory: React.FC = () => {
});
};

const getTransactionIcon = (type: Transaction['type']) => {
switch (type) {
case 'buy':
return <ArrowUpRight className="size-4 text-emerald-400" />;
case 'sell':
return <ArrowDownRight className="size-4 text-rose-400" />;
default:
return <Minus className="size-4 text-white/40" />;
}
};

const getTransactionTypeLabel = (type: Transaction['type']) => {
switch (type) {
case 'buy':
return 'Buy';
case 'sell':
return 'Sell';
default:
return 'Unknown';
}
};

return (
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 md:p-8">
<div className="mb-6 flex items-center justify-between">
Expand All @@ -154,116 +266,15 @@ const TransactionHistory: React.FC = () => {
</div>

<div className="space-y-2">
{SAMPLE_TRANSACTIONS.map(tx => {
const isExpanded = expandedRows.has(tx.id) || !isCompact;
return (
<div
key={tx.id}
className={cn(
'group rounded-xl border border-white/10 bg-white/[0.02] transition-all duration-200 hover:border-white/20 hover:bg-white/[0.04]',
isCompact && !isExpanded && 'py-2',
(!isCompact || isExpanded) && 'p-4'
)}
>
<div className="flex items-center gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5">
{getTransactionIcon(tx.type)}
</div>

<div className="flex min-w-0 flex-1 items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-white">
{getTransactionTypeLabel(tx.type)}
</span>
<span className="text-white/40">•</span>
<span className="text-white/90">{tx.creator}</span>
</div>
{(!isCompact || isExpanded) && (
<div className="mt-1 flex items-center gap-3 text-xs text-white/50">
<span>{tx.amount} keys</span>
<span className="text-white/30">•</span>
<span>{tx.price} ETH</span>
<span className="text-white/30">•</span>
<span>{formatTimestamp(tx.timestamp)}</span>
</div>
)}
</div>

{(!isCompact || isExpanded) && (
<div className="hidden shrink-0 items-center gap-4 text-right sm:flex">
<div className="text-sm">
<div className="font-semibold text-white">
{tx.type === 'buy' ? '+' : '-'}
{(tx.amount * tx.price).toFixed(4)} ETH
</div>
<div className="text-xs text-white/50">
{tx.txHash}
</div>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1">
<div className="size-1.5 rounded-full bg-emerald-400" />
<span className="text-xs font-semibold text-emerald-400">
{tx.status}
</span>
</div>
</div>
)}

{isCompact && !isExpanded && (
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<div className="text-sm font-semibold text-white">
{tx.type === 'buy' ? '+' : '-'}
{(tx.amount * tx.price).toFixed(4)} ETH
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => toggleRowExpansion(tx.id)}
className="h-8 w-8 rounded-lg p-0 text-white/50 hover:bg-white/10 hover:text-white"
>
<ChevronDown className="size-4" />
</Button>
</div>
)}

{isCompact && isExpanded && (
<Button
variant="ghost"
size="sm"
onClick={() => toggleRowExpansion(tx.id)}
className="h-8 w-8 rounded-lg p-0 text-white/50 hover:bg-white/10 hover:text-white"
>
<ChevronUp className="size-4" />
</Button>
)}
</div>
</div>

{isCompact && isExpanded && (
<div className="mt-3 flex items-center justify-between border-t border-white/10 pt-3 text-xs">
<div className="flex items-center gap-3 text-white/50">
<span>{tx.amount} keys</span>
<span className="text-white/30">•</span>
<span>{tx.price} ETH</span>
<span className="text-white/30">•</span>
<span>{formatTimestamp(tx.timestamp)}</span>
<span className="text-white/30">•</span>
<span>{tx.txHash}</span>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1">
<div className="size-1.5 rounded-full bg-emerald-400" />
<span className="text-xs font-semibold text-emerald-400">
{tx.status}
</span>
</div>
</div>
)}
</div>
);
})}
{SAMPLE_TRANSACTIONS.map(tx => (
<TransactionRow
key={tx.id}
tx={tx}
isCompact={isCompact}
isExpanded={expandedRows.has(tx.id) || !isCompact}
onToggleExpansion={toggleRowExpansion}
/>
))}
</div>
</section>
);
Expand Down
13 changes: 10 additions & 3 deletions src/components/home/TrendingCreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,22 @@ import makeBlockie from 'ethereum-blockies-base64';
import { Users, ArrowRight } from 'lucide-react';
import { Link } from 'react-router';
import type { Course } from '@/services/course.service';
import { useFormatXlm } from '@/hooks/formatting';

type Props = { creator: Course & { walletAddress: string } };

export default function TrendingCreatorCard({ creator }: Props) {
const name = creator.title || 'Unnamed creator';
const avatar = makeBlockie(creator.walletAddress);
const priceXlm = creator.priceStroops
? (creator.priceStroops / 1e7).toFixed(2)
: creator.price.toFixed(2);
// Raw stroops arrive as an integer count → pass as bigint so the hook applies
// the shared STROOPS_PER_XLM conversion; the legacy `price` field is already
// denominated in whole XLM and passes through as a number.
const priceXlm = useFormatXlm(
creator.priceStroops
? BigInt(Math.round(creator.priceStroops))
: creator.price,
2
);

return (
<article className="group relative overflow-hidden rounded-2xl border border-black/8 bg-white transition-all duration-300 hover:-translate-y-0.5">
Expand Down
39 changes: 39 additions & 0 deletions src/hooks/__tests__/useFormatXlm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useFormatXlm } from '@/hooks/formatting';

describe('useFormatXlm', () => {
it('defaults to 2 decimal places', () => {
const { result } = renderHook(() => useFormatXlm(12.5));
expect(result.current).toBe('12.50');
});

it('applies locale-aware thousands separators', () => {
const { result } = renderHook(() => useFormatXlm(1234567.891));
expect(result.current).toBe('1,234,567.89');
});

it('honours a configurable decimal override', () => {
const { result } = renderHook(() => useFormatXlm(0.25, 4));
expect(result.current).toBe('0.2500');
});

it('converts a bigint stroop amount to XLM using STROOPS_PER_XLM', () => {
// 25_000_000 stroops = 2.5 XLM (1 XLM = 10^7 stroops).
const { result } = renderHook(() => useFormatXlm(25_000_000n));
expect(result.current).toBe('2.50');
});

it('preserves fractional precision for large bigint stroop amounts', () => {
// 12_345_678_912_345 stroops = 1,234,567.8912345 XLM.
const { result } = renderHook(() =>
useFormatXlm(12_345_678_912_345n, 4)
);
expect(result.current).toBe('1,234,567.8912');
});

it('renders a placeholder for non-finite input', () => {
const { result } = renderHook(() => useFormatXlm(Number.NaN));
expect(result.current).toBe('—');
});
});
Loading
Loading