mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 10:26:33 +02:00
feat: add new widgets for holder analysis, live token data, price, market overview, and trending tokens
- Implemented HolderAnalysisWidget to display holder distribution and concentration risk. - Created LiveTokenDataWidget for real-time market data including price changes and transaction activity. - Added LiveTokenPriceWidget to show current token price and changes over various timeframes. - Developed MarketOverviewWidget to provide a summary of market statistics and token prices. - Introduced TrendingTokensWidget to showcase trending tokens with price changes and volume. - Added TradingSuggestionToolUI for AI-powered trading suggestions with detailed entry, targets, and stop-loss information. - Enhanced settings components for better user configuration options in the SurfSense Browser Extension.
This commit is contained in:
parent
2bf40ab5ce
commit
8bc092e40e
23 changed files with 2173 additions and 111 deletions
|
|
@ -0,0 +1,145 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Users, AlertTriangle, Crown } from "lucide-react";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface Holder {
|
||||
rank: number;
|
||||
address: string;
|
||||
label?: string;
|
||||
balance: number;
|
||||
percentage: number;
|
||||
isContract?: boolean;
|
||||
}
|
||||
|
||||
export interface HolderAnalysisData {
|
||||
tokenSymbol: string;
|
||||
chain: string;
|
||||
totalHolders: number;
|
||||
top10Percentage: number;
|
||||
top50Percentage?: number;
|
||||
holders: Holder[];
|
||||
concentrationRisk?: "low" | "medium" | "high" | "critical";
|
||||
}
|
||||
|
||||
export interface HolderAnalysisWidgetProps {
|
||||
/** Holder analysis data */
|
||||
data: HolderAnalysisData;
|
||||
/** Callback when holder is clicked */
|
||||
onHolderClick?: (holder: Holder) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const shortenAddress = (address: string): string => {
|
||||
return `${address.slice(0, 6)}...${address.slice(-4)}`;
|
||||
};
|
||||
|
||||
const formatBalance = (balance: number): string => {
|
||||
if (balance >= 1e9) return `${(balance / 1e9).toFixed(2)}B`;
|
||||
if (balance >= 1e6) return `${(balance / 1e6).toFixed(2)}M`;
|
||||
if (balance >= 1e3) return `${(balance / 1e3).toFixed(2)}K`;
|
||||
return balance.toFixed(2);
|
||||
};
|
||||
|
||||
const getRiskColor = (risk: string) => {
|
||||
switch (risk) {
|
||||
case "low": return "text-green-500 bg-green-500/10";
|
||||
case "medium": return "text-yellow-500 bg-yellow-500/10";
|
||||
case "high": return "text-orange-500 bg-orange-500/10";
|
||||
case "critical": return "text-red-500 bg-red-500/10";
|
||||
default: return "text-muted-foreground bg-muted";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* HolderAnalysisWidget - Displays holder distribution inline in chat
|
||||
* Used when AI responds to "who holds BULLA?" or "analyze holders"
|
||||
*/
|
||||
export function HolderAnalysisWidget({
|
||||
data,
|
||||
onHolderClick,
|
||||
className,
|
||||
}: HolderAnalysisWidgetProps) {
|
||||
const risk = data.concentrationRisk || "medium";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-purple-500" />
|
||||
<span className="font-medium text-sm">Holder Analysis - {data.tokenSymbol}</span>
|
||||
</div>
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Total Holders</p>
|
||||
<p className="font-medium text-sm">{data.totalHolders.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className={cn("rounded p-2", data.top10Percentage > 50 ? "bg-red-500/10" : "bg-muted/50")}>
|
||||
<p className="text-xs text-muted-foreground">Top 10 Hold</p>
|
||||
<p className={cn("font-medium text-sm", data.top10Percentage > 50 && "text-red-500")}>
|
||||
{data.top10Percentage.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
{data.top50Percentage && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Top 50 Hold</p>
|
||||
<p className="font-medium text-sm">{data.top50Percentage.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("rounded p-2", getRiskColor(risk))}>
|
||||
<p className="text-xs text-muted-foreground">Concentration Risk</p>
|
||||
<p className="font-medium text-sm capitalize">{risk}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Warning */}
|
||||
{(risk === "high" || risk === "critical") && (
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400 text-xs bg-yellow-500/10 rounded-lg p-2 mb-3">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>High holder concentration. Top wallets could impact price.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Holders List */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Top Holders</p>
|
||||
<div className="divide-y max-h-[200px] overflow-y-auto">
|
||||
{data.holders.slice(0, 10).map((holder) => (
|
||||
<div
|
||||
key={holder.address}
|
||||
className="flex items-center justify-between py-2 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
|
||||
onClick={() => onHolderClick?.(holder)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-muted-foreground w-5">#{holder.rank}</span>
|
||||
{holder.rank <= 3 && (
|
||||
<Crown className={cn(
|
||||
"h-3.5 w-3.5",
|
||||
holder.rank === 1 ? "text-yellow-500" :
|
||||
holder.rank === 2 ? "text-gray-400" : "text-amber-600"
|
||||
)} />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-xs">{holder.label || shortenAddress(holder.address)}</p>
|
||||
{holder.isContract && (
|
||||
<span className="text-[10px] bg-muted px-1 rounded">Contract</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-xs">{holder.percentage.toFixed(2)}%</p>
|
||||
<p className="text-[10px] text-muted-foreground">{formatBalance(holder.balance)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,218 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Activity, TrendingUp, TrendingDown, RefreshCw, ExternalLink, Droplets, BarChart3 } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface LiveTokenDataInfo {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
tokenSymbol?: string;
|
||||
tokenName?: string;
|
||||
priceUsd?: string;
|
||||
priceNative?: string;
|
||||
priceChange5m?: number;
|
||||
priceChange1h?: number;
|
||||
priceChange6h?: number;
|
||||
priceChange24h?: number;
|
||||
volume24h?: number;
|
||||
volume6h?: number;
|
||||
volume1h?: number;
|
||||
liquidityUsd?: number;
|
||||
marketCap?: number;
|
||||
fdv?: number;
|
||||
txns24hBuys?: number;
|
||||
txns24hSells?: number;
|
||||
dex?: string;
|
||||
pairUrl?: string;
|
||||
totalPairs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LiveTokenDataWidgetProps {
|
||||
/** Live token data */
|
||||
data: LiveTokenDataInfo;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Callback when view on DexScreener is clicked */
|
||||
onViewDexScreener?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: string | undefined): string => {
|
||||
if (!price || price === "N/A") return "N/A";
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return price;
|
||||
if (num < 0.00001) return `$${num.toExponential(2)}`;
|
||||
if (num < 1) return `$${num.toFixed(6)}`;
|
||||
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number | undefined): string => {
|
||||
if (num === undefined || num === null || num === 0) return "N/A";
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number | undefined): string => {
|
||||
if (num === undefined || num === null) return "0";
|
||||
return num.toLocaleString();
|
||||
};
|
||||
|
||||
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-xs font-medium", isPositive ? "text-green-500" : "text-red-500")}>
|
||||
{isPositive ? "+" : ""}{value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LiveTokenDataWidget - Displays comprehensive real-time market data
|
||||
* Used when AI fetches detailed live market information
|
||||
*/
|
||||
export function LiveTokenDataWidget({
|
||||
data,
|
||||
isLoading = false,
|
||||
onViewDexScreener,
|
||||
className,
|
||||
}: LiveTokenDataWidgetProps) {
|
||||
const handleOpenDexScreener = () => {
|
||||
if (onViewDexScreener) {
|
||||
onViewDexScreener();
|
||||
} else if (data.pairUrl) {
|
||||
window.open(data.pairUrl, "_blank");
|
||||
} else if (data.tokenAddress) {
|
||||
window.open(`https://dexscreener.com/${data.chain}/${data.tokenAddress}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const totalTxns24h = (data.txns24hBuys || 0) + (data.txns24hSells || 0);
|
||||
const buyRatio = totalTxns24h > 0 ? ((data.txns24hBuys || 0) / totalTxns24h) * 100 : 50;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-purple-500/20 bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium text-sm">Live Market Data</span>
|
||||
{isLoading ? (
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded animate-pulse">Fetching...</span>
|
||||
) : (
|
||||
<span className="text-xs text-purple-500 flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Real-time
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.error ? (
|
||||
<div className="text-red-500 text-xs p-2 bg-red-500/10 rounded">
|
||||
⚠️ {data.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{data.tokenSymbol || "Token"}</span>
|
||||
{data.tokenName && (
|
||||
<span className="text-xs text-muted-foreground">{data.tokenName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-lg">{formatPrice(data.priceUsd)}</span>
|
||||
{data.priceChange24h !== undefined && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-medium",
|
||||
data.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.priceChange24h >= 0 ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
|
||||
{data.priceChange24h >= 0 ? "+" : ""}{data.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Changes */}
|
||||
<div className="flex justify-around py-2 bg-muted/30 rounded mb-3">
|
||||
<PriceChange value={data.priceChange5m} label="5m" />
|
||||
<PriceChange value={data.priceChange1h} label="1h" />
|
||||
<PriceChange value={data.priceChange6h} label="6h" />
|
||||
<PriceChange value={data.priceChange24h} label="24h" />
|
||||
</div>
|
||||
|
||||
{/* Metrics Grid */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<BarChart3 className="h-3 w-3" /> 24h Volume
|
||||
</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.volume24h)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||||
<Droplets className="h-3 w-3" /> Liquidity
|
||||
</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.liquidityUsd)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground">Market Cap</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.marketCap)}</p>
|
||||
</div>
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-[10px] text-muted-foreground">FDV</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.fdv)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction Activity */}
|
||||
<div className="space-y-1 mb-3">
|
||||
<p className="text-xs font-medium flex items-center gap-1">
|
||||
<Activity className="h-3 w-3" /> 24h Transactions
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all"
|
||||
style={{ width: `${buyRatio}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-green-500">{formatNumber(data.txns24hBuys)} buys</span>
|
||||
<span className="text-muted-foreground">{formatNumber(totalTxns24h)} total</span>
|
||||
<span className="text-red-500">{formatNumber(data.txns24hSells)} sells</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DEX Info & Actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
<span>DEX: {data.dex || "Unknown"}</span>
|
||||
{data.totalPairs && data.totalPairs > 1 && (
|
||||
<span className="ml-2">• {data.totalPairs} pairs</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleOpenDexScreener}>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
DexScreener
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Zap, TrendingUp, TrendingDown, RefreshCw, ExternalLink } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface LiveTokenPriceData {
|
||||
chain: string;
|
||||
tokenAddress: string;
|
||||
tokenSymbol?: string;
|
||||
tokenName?: string;
|
||||
priceUsd?: string;
|
||||
priceNative?: string;
|
||||
priceChange5m?: number;
|
||||
priceChange1h?: number;
|
||||
priceChange6h?: number;
|
||||
priceChange24h?: number;
|
||||
volume24h?: number;
|
||||
liquidityUsd?: number;
|
||||
marketCap?: number;
|
||||
fdv?: number;
|
||||
dex?: string;
|
||||
pairUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LiveTokenPriceWidgetProps {
|
||||
/** Live token price data */
|
||||
data: LiveTokenPriceData;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Callback when view on DexScreener is clicked */
|
||||
onViewDexScreener?: () => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: string | undefined): string => {
|
||||
if (!price || price === "N/A") return "N/A";
|
||||
const num = parseFloat(price);
|
||||
if (isNaN(num)) return price;
|
||||
if (num < 0.00001) return `$${num.toExponential(2)}`;
|
||||
if (num < 1) return `$${num.toFixed(6)}`;
|
||||
return `$${num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const PriceChange = ({ value, label }: { value: number | undefined; label: string }) => {
|
||||
if (value === undefined || value === null) return null;
|
||||
const isPositive = value >= 0;
|
||||
return (
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-xs font-medium", isPositive ? "text-green-500" : "text-red-500")}>
|
||||
{isPositive ? "+" : ""}{value.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* LiveTokenPriceWidget - Displays real-time token price inline in chat
|
||||
* Used when AI fetches current/live price data
|
||||
*/
|
||||
export function LiveTokenPriceWidget({
|
||||
data,
|
||||
isLoading = false,
|
||||
onViewDexScreener,
|
||||
className,
|
||||
}: LiveTokenPriceWidgetProps) {
|
||||
const handleOpenDexScreener = () => {
|
||||
if (onViewDexScreener) {
|
||||
onViewDexScreener();
|
||||
} else if (data.pairUrl) {
|
||||
window.open(data.pairUrl, "_blank");
|
||||
} else if (data.tokenAddress) {
|
||||
window.open(`https://dexscreener.com/${data.chain}/${data.tokenAddress}`, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-blue-500/20 bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium text-sm">Live Price</span>
|
||||
{isLoading ? (
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded animate-pulse">Fetching...</span>
|
||||
) : (
|
||||
<span className="text-xs text-blue-500 flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Real-time
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.error ? (
|
||||
<div className="text-red-500 text-xs p-2 bg-red-500/10 rounded">
|
||||
⚠️ {data.error}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Token Header */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<ChainIcon chain={data.chain} size="sm" />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold">{data.tokenSymbol || "Token"}</span>
|
||||
{data.tokenName && (
|
||||
<span className="text-xs text-muted-foreground">{data.tokenName}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-lg">{formatPrice(data.priceUsd)}</span>
|
||||
{data.priceChange24h !== undefined && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-0.5 text-xs font-medium",
|
||||
data.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{data.priceChange24h >= 0 ? "+" : ""}{data.priceChange24h.toFixed(2)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Changes */}
|
||||
<div className="flex justify-around py-2 bg-muted/30 rounded mb-3">
|
||||
<PriceChange value={data.priceChange5m} label="5m" />
|
||||
<PriceChange value={data.priceChange1h} label="1h" />
|
||||
<PriceChange value={data.priceChange6h} label="6h" />
|
||||
<PriceChange value={data.priceChange24h} label="24h" />
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={handleOpenDexScreener}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View on DexScreener
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Globe, TrendingUp, TrendingDown } from "lucide-react";
|
||||
|
||||
export interface MarketToken {
|
||||
symbol: string;
|
||||
name: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
marketCap?: number;
|
||||
volume24h?: number;
|
||||
}
|
||||
|
||||
export interface MarketOverviewData {
|
||||
tokens: MarketToken[];
|
||||
totalMarketCap?: number;
|
||||
totalVolume24h?: number;
|
||||
btcDominance?: number;
|
||||
fearGreedIndex?: number;
|
||||
}
|
||||
|
||||
export interface MarketOverviewWidgetProps {
|
||||
/** Market overview data */
|
||||
data: MarketOverviewData;
|
||||
/** Callback when token is clicked */
|
||||
onTokenClick?: (token: MarketToken) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 1) return `$${price.toFixed(4)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
const getFearGreedLabel = (index: number): string => {
|
||||
if (index > 75) return "Extreme Greed";
|
||||
if (index > 50) return "Greed";
|
||||
if (index > 25) return "Fear";
|
||||
return "Extreme Fear";
|
||||
};
|
||||
|
||||
/**
|
||||
* MarketOverviewWidget - Displays market overview inline in chat
|
||||
* Used when AI responds to "show market overview" or "how's the market?"
|
||||
*/
|
||||
export function MarketOverviewWidget({
|
||||
data,
|
||||
onTokenClick,
|
||||
className,
|
||||
}: MarketOverviewWidgetProps) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="h-5 w-5 text-blue-500" />
|
||||
<span className="font-medium text-sm">Market Overview</span>
|
||||
</div>
|
||||
|
||||
{/* Global Stats */}
|
||||
{(data.totalMarketCap || data.btcDominance || data.fearGreedIndex) && (
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
{data.totalMarketCap && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">Total Market Cap</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.totalMarketCap)}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.totalVolume24h && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">24h Volume</p>
|
||||
<p className="font-medium text-sm">{formatLargeNumber(data.totalVolume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
{data.btcDominance && (
|
||||
<div className="bg-muted/50 rounded p-2">
|
||||
<p className="text-xs text-muted-foreground">BTC Dominance</p>
|
||||
<p className="font-medium text-sm">{data.btcDominance.toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
{data.fearGreedIndex && (
|
||||
<div className={cn(
|
||||
"rounded p-2",
|
||||
data.fearGreedIndex > 50 ? "bg-green-500/10" : "bg-red-500/10"
|
||||
)}>
|
||||
<p className="text-xs text-muted-foreground">Fear & Greed</p>
|
||||
<p className={cn(
|
||||
"font-medium text-sm",
|
||||
data.fearGreedIndex > 50 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{data.fearGreedIndex} - {getFearGreedLabel(data.fearGreedIndex)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token Prices */}
|
||||
<div className="space-y-2">
|
||||
{data.tokens.map((token) => (
|
||||
<div
|
||||
key={token.symbol}
|
||||
className="bg-muted/50 rounded p-3 flex items-center justify-between hover:bg-muted/70 cursor-pointer transition-colors"
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-bold">{token.symbol}</p>
|
||||
<p className="text-xs text-muted-foreground">{token.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-medium">{formatPrice(token.price)}</p>
|
||||
<p className={cn(
|
||||
"text-xs flex items-center justify-end gap-0.5",
|
||||
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{token.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import { Flame, TrendingUp, TrendingDown, Star } from "lucide-react";
|
||||
import { Button } from "@/routes/ui/button";
|
||||
import { ChainIcon } from "../components/shared/ChainIcon";
|
||||
|
||||
export interface TrendingToken {
|
||||
symbol: string;
|
||||
name: string;
|
||||
chain: string;
|
||||
contractAddress?: string;
|
||||
price: number;
|
||||
priceChange24h: number;
|
||||
priceChange1h?: number;
|
||||
volume24h?: number;
|
||||
liquidity?: number;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface TrendingTokensWidgetProps {
|
||||
/** List of trending tokens */
|
||||
tokens: TrendingToken[];
|
||||
/** Filter by chain (optional) */
|
||||
chain?: string;
|
||||
/** Timeframe for trending data */
|
||||
timeframe?: string;
|
||||
/** Callback when token is clicked */
|
||||
onTokenClick?: (token: TrendingToken) => void;
|
||||
/** Callback when add to watchlist is clicked */
|
||||
onAddToWatchlist?: (token: TrendingToken) => void;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const formatPrice = (price: number): string => {
|
||||
if (price < 0.00001) return `$${price.toExponential(2)}`;
|
||||
if (price < 1) return `$${price.toFixed(6)}`;
|
||||
return `$${price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
};
|
||||
|
||||
const formatLargeNumber = (num: number): string => {
|
||||
if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
|
||||
if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
|
||||
if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
|
||||
return `$${num.toFixed(2)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* TrendingTokensWidget - Displays trending/hot tokens inline in chat
|
||||
* Used when AI responds to "what's hot on Solana?" or "show trending tokens"
|
||||
*/
|
||||
export function TrendingTokensWidget({
|
||||
tokens,
|
||||
chain = "All Chains",
|
||||
timeframe = "24h",
|
||||
onTokenClick,
|
||||
onAddToWatchlist,
|
||||
className,
|
||||
}: TrendingTokensWidgetProps) {
|
||||
return (
|
||||
<div className={cn("rounded-lg border bg-card p-4 my-2", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Flame className="h-5 w-5 text-orange-500" />
|
||||
<span className="font-medium text-sm">Trending on {chain}</span>
|
||||
<span className="text-xs bg-muted px-2 py-0.5 rounded">{timeframe}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token List */}
|
||||
{tokens.length === 0 ? (
|
||||
<p className="text-muted-foreground text-center py-4 text-sm">No trending tokens found</p>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{tokens.map((token, index) => (
|
||||
<div
|
||||
key={token.symbol + index}
|
||||
className="flex items-center justify-between py-2.5 hover:bg-muted/50 -mx-2 px-2 rounded cursor-pointer transition-colors"
|
||||
onClick={() => onTokenClick?.(token)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-muted-foreground w-5">
|
||||
#{token.rank || index + 1}
|
||||
</span>
|
||||
<ChainIcon chain={token.chain} size="xs" />
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-sm">{token.symbol}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{token.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<p className="font-medium text-sm">{formatPrice(token.price)}</p>
|
||||
<p className={cn(
|
||||
"text-xs flex items-center justify-end gap-0.5",
|
||||
token.priceChange24h >= 0 ? "text-green-500" : "text-red-500"
|
||||
)}>
|
||||
{token.priceChange24h >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{token.priceChange24h >= 0 ? "+" : ""}{token.priceChange24h.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
{token.volume24h && (
|
||||
<div className="text-right hidden sm:block">
|
||||
<p className="text-xs text-muted-foreground">Vol</p>
|
||||
<p className="text-xs">{formatLargeNumber(token.volume24h)}</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToWatchlist?.(token);
|
||||
}}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -9,12 +9,19 @@ export { TokenAnalysisWidget, type TokenAnalysisWidgetProps, type TokenAnalysisD
|
|||
|
||||
// Epic 2: Smart Monitoring & Alerts
|
||||
export { WhaleActivityWidget, type WhaleActivityWidgetProps } from "./WhaleActivityWidget";
|
||||
export { TrendingTokensWidget, type TrendingTokensWidgetProps, type TrendingToken } from "./TrendingTokensWidget";
|
||||
|
||||
// Epic 3: Trading Intelligence
|
||||
export { TradingSuggestionWidget, type TradingSuggestionWidgetProps } from "./TradingSuggestionWidget";
|
||||
export { PortfolioWidget, type PortfolioWidgetProps } from "./PortfolioWidget";
|
||||
export { HolderAnalysisWidget, type HolderAnalysisWidgetProps, type HolderAnalysisData, type Holder } from "./HolderAnalysisWidget";
|
||||
|
||||
// Epic 4: Content Creation & Productivity
|
||||
export { ChartCaptureWidget, type ChartCaptureWidgetProps } from "./ChartCaptureWidget";
|
||||
export { ThreadGeneratorWidget, type ThreadGeneratorWidgetProps } from "./ThreadGeneratorWidget";
|
||||
|
||||
// Market Data Widgets
|
||||
export { MarketOverviewWidget, type MarketOverviewWidgetProps, type MarketOverviewData, type MarketToken } from "./MarketOverviewWidget";
|
||||
export { LiveTokenPriceWidget, type LiveTokenPriceWidgetProps, type LiveTokenPriceData } from "./LiveTokenPriceWidget";
|
||||
export { LiveTokenDataWidget, type LiveTokenDataWidgetProps, type LiveTokenDataInfo } from "./LiveTokenDataWidget";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue