From 497ba9102708f836fff6f04d2871b9a1f954bd78 Mon Sep 17 00:00:00 2001 From: Fayyo <94748999+Fayyo@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:09:10 +0000 Subject: [PATCH] feat: implement Stellar asset conversion and exchange rate ticker (#64) - Add AssetRate type to domain models - Create stellar formatting utilities (formatStellarPrice, formatAssetAmount, convertToUsd, sortAssetRates) - Add mock rate data with XLM, USDC, yUSDF, and AQUA assets - Integrate React Query for fetching and caching rate feeds with 30s polling - Build AssetTicker component with responsive layout, Lucide icons, and 24h change indicators - Skeleton loading states prevent layout shift during data fetch --- src/components/dashboard/AssetTicker.tsx | 159 +++++++++++++++++++++++ src/hooks/use-queries.ts | 9 ++ src/lib/stellar.ts | 78 +++++++++++ src/services/mock/index.ts | 1 + src/services/mock/rates.ts | 34 +++++ src/services/query-keys.ts | 1 + src/services/resources.ts | 5 + src/types/domain.ts | 15 +++ 8 files changed, 302 insertions(+) create mode 100644 src/components/dashboard/AssetTicker.tsx create mode 100644 src/lib/stellar.ts create mode 100644 src/services/mock/rates.ts diff --git a/src/components/dashboard/AssetTicker.tsx b/src/components/dashboard/AssetTicker.tsx new file mode 100644 index 0000000..18e7b88 --- /dev/null +++ b/src/components/dashboard/AssetTicker.tsx @@ -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 = { + 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 ( + + {isPositive ? ( + + ) : isNeutral ? ( + + ) : ( + + )} + {isPositive ? '+' : ''} + {change24h.toFixed(2)}% + + ); +} + +function RateRow({ rate }: { rate: AssetRate }) { + const colorClass = assetColors[rate.asset] ?? 'bg-surface-secondary text-foreground-secondary'; + + return ( +
+
+ + {rate.asset.slice(0, 3)} + +
+

{rate.asset}

+

+ {rate.source} +

+
+
+
+
+

+ {formatStellarPrice(rate.priceUsd)} +

+
+
+ +
+
+
+ ); +} + +function RateRowSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// 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 ( + +
+
+

+ Asset rates +

+ Live +
+
+ + + {rates.dataUpdatedAt + ? `Updated ${formatRelativeTime(new Date(rates.dataUpdatedAt).toISOString())}` + : 'Fetching…'} + +
+
+ +
+ {rates.isPending + ? Array.from({ length: 4 }).map((_, i) => ) + : sortAssetRates(rates.data ?? []).map((rate) => ( + + ))} +
+ +
+ Prices sourced from Stellar DEX & off-chain oracles. 24h change is approximate. +
+
+ ); +} diff --git a/src/hooks/use-queries.ts b/src/hooks/use-queries.ts index dcb3d8c..8353e34 100644 --- a/src/hooks/use-queries.ts +++ b/src/hooks/use-queries.ts @@ -9,6 +9,7 @@ import type { AnalyticsOverview, ApiKey, AppNotification, + AssetRate, Budget, ChatMessage, MemoryRecord, @@ -133,6 +134,14 @@ export const useMemoryRecord = (id: string): UseQueryResult => enabled: Boolean(id), }); +// -- asset rates ------------------------------------------------------------- +export const useAssetRates = (): UseQueryResult => + useQuery({ + queryKey: queryKeys.rates, + queryFn: resources.getAssetRates, + refetchInterval: 30_000, + }); + // -- notifications ---------------------------------------------------------- export const useNotifications = (): UseQueryResult => useQuery({ queryKey: queryKeys.notifications, queryFn: resources.getNotifications }); diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts new file mode 100644 index 0000000..786b9d0 --- /dev/null +++ b/src/lib/stellar.ts @@ -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); + }); +} diff --git a/src/services/mock/index.ts b/src/services/mock/index.ts index 2f30998..2b47861 100644 --- a/src/services/mock/index.ts +++ b/src/services/mock/index.ts @@ -2,3 +2,4 @@ export * from './entities'; export * from './transactions'; export * from './memory'; export * from './analytics'; +export * from './rates'; diff --git a/src/services/mock/rates.ts b/src/services/mock/rates.ts new file mode 100644 index 0000000..22c735c --- /dev/null +++ b/src/services/mock/rates.ts @@ -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', + }, +]; diff --git a/src/services/query-keys.ts b/src/services/query-keys.ts index ad13e31..0594f9a 100644 --- a/src/services/query-keys.ts +++ b/src/services/query-keys.ts @@ -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, diff --git a/src/services/resources.ts b/src/services/resources.ts index 16cb994..b6c2feb 100644 --- a/src/services/resources.ts +++ b/src/services/resources.ts @@ -7,6 +7,7 @@ import type { AnalyticsOverview, ApiKey, AppNotification, + AssetRate, Budget, ChatMessage, MemoryRecord, @@ -182,6 +183,10 @@ export const resources = { () => one(`/memory/${id}`), ), + // -- asset rates --------------------------------------------------------- + getAssetRates: (): Promise => + resolve(mock.assetRates, () => list('/rates')), + // -- notifications ------------------------------------------------------ getNotifications: (): Promise => resolve(mock.notifications, () => list('/notifications')), diff --git a/src/types/domain.ts b/src/types/domain.ts index f9fd79d..c4805e2 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -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; +}