Skip to content
Merged
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
159 changes: 159 additions & 0 deletions src/components/dashboard/AssetTicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
'use client';

import { TrendingUp, TrendingDown, Minus, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/cn';
import { useAssetRates } from '@/hooks/use-queries';
import { formatStellarPrice, sortAssetRates } from '@/lib/stellar';
import { formatRelativeTime } from '@/lib/format';
import type { AssetRate } from '@/types/domain';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const assetColors: Record<string, string> = {
XLM: 'bg-info-soft text-info',
USDC: 'bg-success-soft text-success',
yUSDF: 'bg-warning-soft text-warning',
AQUA: 'bg-gold-soft text-gold-strong',
};

function ChangeIndicator({ change24h }: { change24h: number }) {
const isPositive = change24h > 0;
const isNeutral = change24h === 0;

return (
<span
className={cn(
'inline-flex items-center gap-0.5 text-2xs font-medium tabular',
isPositive && 'text-success',
!isPositive && !isNeutral && 'text-danger',
isNeutral && 'text-foreground-muted',
)}
>
{isPositive ? (
<TrendingUp className="h-3 w-3" aria-hidden />
) : isNeutral ? (
<Minus className="h-3 w-3" aria-hidden />
) : (
<TrendingDown className="h-3 w-3" aria-hidden />
)}
{isPositive ? '+' : ''}
{change24h.toFixed(2)}%
</span>
);
}

function RateRow({ rate }: { rate: AssetRate }) {
const colorClass = assetColors[rate.asset] ?? 'bg-surface-secondary text-foreground-secondary';

return (
<div className="flex items-center justify-between gap-3 px-4 py-3 transition-colors hover:bg-surface-secondary/50">
<div className="flex items-center gap-3">
<span
className={cn(
'grid h-8 w-8 place-items-center rounded-md text-xs font-bold',
colorClass,
)}
aria-hidden
>
{rate.asset.slice(0, 3)}
</span>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">{rate.asset}</p>
<p className="text-2xs text-foreground-muted">
{rate.source}
</p>
</div>
</div>
<div className="flex items-center gap-4 tabular">
<div className="text-right">
<p className="text-sm font-semibold text-foreground">
{formatStellarPrice(rate.priceUsd)}
</p>
</div>
<div className="w-20 text-right">
<ChangeIndicator change24h={rate.change24h} />
</div>
</div>
</div>
);
}

function RateRowSkeleton() {
return (
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" rounded="md" />
<div className="space-y-1.5">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-2.5 w-20" />
</div>
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-3 w-14" />
</div>
</div>
);
}

// ---------------------------------------------------------------------------
// AssetTicker — real-time Stellar asset conversion & exchange rate display
// ---------------------------------------------------------------------------

export interface AssetTickerProps {
className?: string;
}

/**
* Displays a live ticker of Stellar-native and custom token exchange rates.
* Polls every 30 seconds via React Query's `refetchInterval` on the
* `useAssetRates` hook. Includes skeleton loading states, 24h change
* indicators, and is fully responsive.
*/
export function AssetTicker({ className }: AssetTickerProps) {
const rates = useAssetRates();

return (
<Card elevation="soft" className={cn('overflow-hidden', className)}>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<h2 className="font-display text-sm font-semibold tracking-tight text-foreground">
Asset rates
</h2>
<Badge variant="info" size="sm">Live</Badge>
</div>
<div className="flex items-center gap-2 text-2xs text-foreground-muted">
<RefreshCw
className={cn(
'h-3 w-3',
rates.isFetching && 'animate-spin text-gold',
)}
aria-hidden
/>
<span className="tabular">
{rates.dataUpdatedAt
? `Updated ${formatRelativeTime(new Date(rates.dataUpdatedAt).toISOString())}`
: 'Fetching…'}
</span>
</div>
</div>

<div className="divide-y divide-border">
{rates.isPending
? Array.from({ length: 4 }).map((_, i) => <RateRowSkeleton key={i} />)
: sortAssetRates(rates.data ?? []).map((rate) => (
<RateRow key={rate.asset} rate={rate} />
))}
</div>

<div className="border-t border-border px-4 py-2.5 text-center text-2xs text-foreground-muted">
Prices sourced from Stellar DEX &amp; off-chain oracles. 24h change is approximate.
</div>
</Card>
);
}
9 changes: 9 additions & 0 deletions src/hooks/use-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
AnalyticsOverview,
ApiKey,
AppNotification,
AssetRate,
Budget,
ChatMessage,
MemoryRecord,
Expand Down Expand Up @@ -133,6 +134,14 @@ export const useMemoryRecord = (id: string): UseQueryResult<MemoryRecord> =>
enabled: Boolean(id),
});

// -- asset rates -------------------------------------------------------------
export const useAssetRates = (): UseQueryResult<AssetRate[]> =>
useQuery({
queryKey: queryKeys.rates,
queryFn: resources.getAssetRates,
refetchInterval: 30_000,
});

// -- notifications ----------------------------------------------------------
export const useNotifications = (): UseQueryResult<AppNotification[]> =>
useQuery({ queryKey: queryKeys.notifications, queryFn: resources.getNotifications });
Expand Down
78 changes: 78 additions & 0 deletions src/lib/stellar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { AssetRate } from '@/types/domain';

/**
* Format a Stellar asset price with appropriate precision.
* - XLM / native: 4 decimal places (e.g. 0.1184)
* - Stablecoins (USDC, yUSDF): 2–4 decimals based on magnitude
* - Small-cap tokens: scientific-like with significant figures
*/
export function formatStellarPrice(priceUsd: number): string {
if (priceUsd === 0) return '$0.00';
if (priceUsd >= 1) {
return `$${new Intl.NumberFormat('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 4,
}).format(priceUsd)}`;
}
if (priceUsd >= 0.01) {
return `$${priceUsd.toFixed(4)}`;
}
// Sub-cent assets — show enough precision to be meaningful
return `$${priceUsd.toFixed(6)}`;
}

/**
* Format an asset amount with its symbol for display in the ticker.
* Stellar standard precision: 7 decimal places max (stroops).
*/
export function formatAssetAmount(
amount: number,
asset: string,
options: { compact?: boolean } = {},
): string {
const { compact = false } = options;

if (compact) {
const formatted = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
}).format(amount);
return `${formatted} ${asset}`;
}

const maxDecimals = asset === 'XLM' || asset === 'AQUA' ? 4 : 2;
const formatted = new Intl.NumberFormat('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: maxDecimals,
}).format(amount);
return `${formatted} ${asset}`;
}

/**
* Convert an amount of a Stellar asset to USD using a rate lookup.
*/
export function convertToUsd(
amount: number,
rates: AssetRate[],
asset: string,
): number | null {
const rate = rates.find((r) => r.asset === asset);
if (!rate) return null;
return amount * rate.priceUsd;
}

/**
* Sort asset rates with native XLM first, then stablecoins, then alphabetical.
*/
export function sortAssetRates(rates: AssetRate[]): AssetRate[] {
const stablecoins = new Set(['USDC', 'yUSDF']);
const native = new Set(['XLM']);

return [...rates].sort((a, b) => {
if (native.has(a.asset)) return -1;
if (native.has(b.asset)) return 1;
if (stablecoins.has(a.asset) && !stablecoins.has(b.asset)) return -1;
if (!stablecoins.has(a.asset) && stablecoins.has(b.asset)) return 1;
return a.asset.localeCompare(b.asset);
});
}
1 change: 1 addition & 0 deletions src/services/mock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './entities';
export * from './transactions';
export * from './memory';
export * from './analytics';
export * from './rates';
34 changes: 34 additions & 0 deletions src/services/mock/rates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { AssetRate } from '@/types/domain';

const now = new Date().toISOString();

export const assetRates: AssetRate[] = [
{
asset: 'XLM',
priceUsd: 0.1184,
change24h: 2.37,
updatedAt: now,
source: 'mock-stellar-dex',
},
{
asset: 'USDC',
priceUsd: 1.0001,
change24h: -0.01,
updatedAt: now,
source: 'mock-circle',
},
{
asset: 'yUSDF',
priceUsd: 1.012,
change24h: 0.42,
updatedAt: now,
source: 'mock-protocol',
},
{
asset: 'AQUA',
priceUsd: 0.00284,
change24h: -4.15,
updatedAt: now,
source: 'mock-stellar-dex',
},
];
1 change: 1 addition & 0 deletions src/services/query-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const queryKeys = {
proposal: (id: string) => ['proposals', id] as const,
memory: ['memory'] as const,
memoryRecord: (id: string) => ['memory', id] as const,
rates: ['rates'] as const,
notifications: ['notifications'] as const,
apiKeys: ['developer', 'keys'] as const,
webhooks: ['developer', 'webhooks'] as const,
Expand Down
5 changes: 5 additions & 0 deletions src/services/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
AnalyticsOverview,
ApiKey,
AppNotification,
AssetRate,
Budget,
ChatMessage,
MemoryRecord,
Expand Down Expand Up @@ -182,6 +183,10 @@ export const resources = {
() => one<MemoryRecord>(`/memory/${id}`),
),

// -- asset rates ---------------------------------------------------------
getAssetRates: (): Promise<AssetRate[]> =>
resolve(mock.assetRates, () => list<AssetRate[]>('/rates')),

// -- notifications ------------------------------------------------------
getNotifications: (): Promise<AppNotification[]> =>
resolve(mock.notifications, () => list<AppNotification[]>('/notifications')),
Expand Down
15 changes: 15 additions & 0 deletions src/types/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,18 @@ export interface ChatMessage {
content: string;
createdAt: string;
}

// ---------------------------------------------------------------------------
// Asset Rates & Conversion
// ---------------------------------------------------------------------------
export interface AssetRate {
asset: Asset;
/** Price in USD (e.g. 0.12 for XLM) */
priceUsd: number;
/** 24-hour percentage change */
change24h: number;
/** ISO 8601 timestamp of the last update */
updatedAt: string;
/** Source feed identifier */
source: string;
}
Loading