feat: Smart AI Trading Bot for XAUUSD with ML and SMC

- XGBoost ML model with 37 features for market direction prediction
- Smart Money Concepts (SMC): Order Blocks, FVG, BOS, CHoCH
- HMM market regime detection (trending/ranging/volatile)
- ATR-based stop loss with 1.5 ATR minimum distance
- Broker-level SL protection with fallback
- Time-based exit (max 6 hours per trade)
- Session-aware trading optimized for London/NY overlap
- Auto-retraining based on market conditions
- Telegram notifications and web dashboard
- Backtest results: 63.9% win rate, 2.64 profit factor, 4.83 Sharpe

Backtest period: Jan 2025 - Feb 2026, 654 trades, $4,189 net P/L

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
GifariKemal
2026-02-06 09:01:35 +07:00
co-authored by Claude Opus 4.5
commit 7af9183af3
121 changed files with 43387 additions and 0 deletions
@@ -0,0 +1,41 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Wallet } from "lucide-react";
interface AccountCardProps {
balance: number;
equity: number;
profit: number;
}
export function AccountCard({ balance, equity, profit }: AccountCardProps) {
const isProfit = profit >= 0;
return (
<Card className="bg-card/50 backdrop-blur">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wallet className="h-4 w-4" />
ACCOUNT
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Balance</span>
<span className="font-semibold">${balance.toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Equity</span>
<span className="font-semibold">${equity.toLocaleString(undefined, { minimumFractionDigits: 2 })}</span>
</div>
<div className="flex justify-between items-center pt-2 border-t">
<span className="text-sm text-muted-foreground">P/L</span>
<span className={`font-bold ${isProfit ? 'text-green-500' : 'text-red-500'}`}>
{isProfit ? '+' : ''}${profit.toFixed(2)}
</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,78 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
import { Wallet } from "lucide-react";
interface EquityChartProps {
equityData: number[];
balanceData: number[];
}
export function EquityChart({ equityData, balanceData }: EquityChartProps) {
const chartData = equityData.map((equity, i) => ({
index: i,
equity,
balance: balanceData[i] || equity,
}));
return (
<Card className="bg-card/50 backdrop-blur col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wallet className="h-4 w-4" />
EQUITY vs BALANCE (2H)
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[120px] w-full">
{equityData.length > 1 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<XAxis dataKey="index" hide />
<YAxis domain={['auto', 'auto']} hide />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
labelStyle={{ display: 'none' }}
formatter={(value: number, name: string) => [
`$${value.toFixed(2)}`,
name === 'equity' ? 'Equity' : 'Balance'
]}
/>
<defs>
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
stroke="#666"
strokeWidth={1}
strokeDasharray="3 3"
fill="none"
/>
<Area
type="monotone"
dataKey="equity"
stroke="#22c55e"
strokeWidth={2}
fill="url(#equityGradient)"
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
Waiting for data...
</div>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,47 @@
"use client";
import { Badge } from "@/components/ui/badge";
import { Bot, Wifi, WifiOff, Clock } from "lucide-react";
interface HeaderProps {
connected: boolean;
lastUpdate: string;
dataAge: number;
}
export function Header({ connected, lastUpdate, dataAge }: HeaderProps) {
const isStale = dataAge > 5;
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center justify-between">
<div className="flex items-center gap-3">
<Bot className="h-6 w-6 text-primary" />
<div className="flex items-baseline gap-2">
<h1 className="text-lg font-bold">AI TRADING BOT</h1>
<span className="text-xs text-primary font-semibold">MONITOR</span>
</div>
</div>
<div className="flex items-center gap-4">
{/* Data Freshness */}
<Badge variant={isStale ? "destructive" : "secondary"} className="gap-1">
<Clock className="h-3 w-3" />
{isStale ? `STALE (${dataAge.toFixed(0)}s)` : `LIVE (${dataAge.toFixed(1)}s)`}
</Badge>
{/* Connection Status */}
<Badge variant={connected ? "default" : "destructive"} className="gap-1">
{connected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
{connected ? 'Connected' : 'Disconnected'}
</Badge>
{/* Time */}
<span className="text-sm font-medium text-muted-foreground">
{lastUpdate || '--:--:--'} WIB
</span>
</div>
</div>
</header>
);
}
@@ -0,0 +1,11 @@
export { PriceCard } from './price-card';
export { AccountCard } from './account-card';
export { SessionCard } from './session-card';
export { RiskCard } from './risk-card';
export { SignalCard } from './signal-card';
export { RegimeCard } from './regime-card';
export { PositionsCard } from './positions-card';
export { LogCard } from './log-card';
export { PriceChart } from './price-chart';
export { EquityChart } from './equity-chart';
export { Header } from './header';
@@ -0,0 +1,60 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Terminal } from "lucide-react";
import type { LogEntry } from "@/types/trading";
interface LogCardProps {
logs: LogEntry[];
}
export function LogCard({ logs }: LogCardProps) {
const getLevelColor = (level: string) => {
switch (level) {
case 'error': return 'text-red-500';
case 'warn': return 'text-amber-500';
case 'trade': return 'text-cyan-400';
default: return 'text-green-400';
}
};
const getLevelBadge = (level: string) => {
switch (level) {
case 'error': return 'ERR';
case 'warn': return 'WRN';
case 'trade': return 'TRD';
default: return 'INF';
}
};
return (
<Card className="bg-card/50 backdrop-blur col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Terminal className="h-4 w-4" />
AI ACTIVITY LOG
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[150px] rounded-md bg-black/50 p-3 font-mono text-xs">
{logs.length === 0 ? (
<p className="text-muted-foreground">Waiting for activity...</p>
) : (
<div className="space-y-1">
{logs.map((log, i) => (
<div key={i} className="flex gap-2">
<span className="text-muted-foreground">[{log.time}]</span>
<span className={`font-semibold ${getLevelColor(log.level)}`}>
[{getLevelBadge(log.level)}]
</span>
<span className="text-foreground/80">{log.message}</span>
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}
@@ -0,0 +1,55 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Layers } from "lucide-react";
import type { Position } from "@/types/trading";
interface PositionsCardProps {
positions: Position[];
}
export function PositionsCard({ positions }: PositionsCardProps) {
return (
<Card className="bg-card/50 backdrop-blur">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Layers className="h-4 w-4" />
OPEN POSITIONS
{positions.length > 0 && (
<Badge variant="secondary" className="ml-auto">{positions.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[100px]">
{positions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No open positions
</p>
) : (
<div className="space-y-2">
{positions.map((pos) => (
<div
key={pos.ticket}
className="flex items-center justify-between p-2 rounded-md bg-muted/50"
>
<div className="flex items-center gap-2">
<Badge variant={pos.type === 'BUY' ? 'default' : 'destructive'} className="text-xs">
{pos.type}
</Badge>
<span className="text-sm">{pos.volume} @ {pos.priceOpen.toFixed(2)}</span>
</div>
<span className={`font-semibold ${pos.profit >= 0 ? 'text-green-500' : 'text-red-500'}`}>
{pos.profit >= 0 ? '+' : ''}${pos.profit.toFixed(2)}
</span>
</div>
))}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
);
}
@@ -0,0 +1,45 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { TrendingUp, TrendingDown } from "lucide-react";
interface PriceCardProps {
price: number;
spread: number;
priceChange: number;
}
export function PriceCard({ price, spread, priceChange }: PriceCardProps) {
const isUp = priceChange >= 0;
return (
<Card className="bg-card/50 backdrop-blur">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
PRICE
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<span className={`text-3xl font-bold ${isUp ? 'text-green-500' : 'text-red-500'}`}>
{price.toFixed(2)}
</span>
<span className="text-xs text-muted-foreground">XAUUSD</span>
</div>
<div className="flex items-center gap-2 mt-2">
{isUp ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<span className={`text-sm ${isUp ? 'text-green-500' : 'text-red-500'}`}>
{isUp ? '+' : ''}{priceChange.toFixed(2)}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Spread: {spread.toFixed(1)} pips
</p>
</CardContent>
</Card>
);
}
@@ -0,0 +1,63 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LineChart, Line, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
import { TrendingUp } from "lucide-react";
interface PriceChartProps {
data: number[];
}
export function PriceChart({ data }: PriceChartProps) {
const chartData = data.map((price, i) => ({ index: i, price }));
return (
<Card className="bg-card/50 backdrop-blur col-span-2">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
PRICE CHART (2H)
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[120px] w-full">
{data.length > 1 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<XAxis dataKey="index" hide />
<YAxis domain={['auto', 'auto']} hide />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
labelStyle={{ display: 'none' }}
formatter={(value: number) => [`$${value.toFixed(2)}`, 'Price']}
/>
<defs>
<linearGradient id="priceGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="hsl(var(--primary))" stopOpacity={0.3} />
<stop offset="95%" stopColor="hsl(var(--primary))" stopOpacity={0} />
</linearGradient>
</defs>
<Line
type="monotone"
dataKey="price"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
fill="url(#priceGradient)"
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
Waiting for data...
</div>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,45 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Activity } from "lucide-react";
interface RegimeCardProps {
name: string;
volatility: number;
confidence: number;
}
export function RegimeCard({ name, volatility, confidence }: RegimeCardProps) {
const getRegimeColor = (regime: string) => {
if (regime.toLowerCase().includes('high')) return 'text-red-500';
if (regime.toLowerCase().includes('low')) return 'text-green-500';
return 'text-amber-500';
};
return (
<Card className="bg-card/50 backdrop-blur">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Activity className="h-4 w-4" />
MARKET REGIME
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<span className={`text-lg font-bold ${getRegimeColor(name)}`}>
{name || '---'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Volatility</span>
<span className="font-semibold">{volatility.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Confidence</span>
<span className="font-semibold">{(confidence * 100).toFixed(0)}%</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,55 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { ShieldAlert } from "lucide-react";
interface RiskCardProps {
dailyLoss: number;
dailyProfit: number;
consecutiveLosses: number;
riskPercent: number;
}
export function RiskCard({ dailyLoss, dailyProfit, consecutiveLosses, riskPercent }: RiskCardProps) {
const isHighRisk = riskPercent >= 80;
const isMediumRisk = riskPercent >= 50;
return (
<Card className={`bg-card/50 backdrop-blur ${isHighRisk ? 'border-red-500 border-2 animate-pulse' : ''}`}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<ShieldAlert className={`h-4 w-4 ${isHighRisk ? 'text-red-500' : ''}`} />
RISK STATUS
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Daily Loss</span>
<span className="font-semibold text-red-500">${dailyLoss.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Daily Profit</span>
<span className="font-semibold text-green-500">${dailyProfit.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Consec. Losses</span>
<span className="font-semibold">{consecutiveLosses}</span>
</div>
<div className="pt-2 border-t">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-muted-foreground">Risk Used</span>
<span className={`font-bold ${isHighRisk ? 'text-red-500' : isMediumRisk ? 'text-amber-500' : 'text-green-500'}`}>
{riskPercent.toFixed(0)}%
</span>
</div>
<Progress
value={riskPercent}
className={`h-2 ${isHighRisk ? '[&>div]:bg-red-500' : isMediumRisk ? '[&>div]:bg-amber-500' : '[&>div]:bg-green-500'}`}
/>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,44 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Clock, Sparkles } from "lucide-react";
interface SessionCardProps {
session: string;
isGoldenTime: boolean;
canTrade: boolean;
}
export function SessionCard({ session, isGoldenTime, canTrade }: SessionCardProps) {
return (
<Card className="bg-card/50 backdrop-blur">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
SESSION
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<span className="text-lg font-bold text-amber-500">{session}</span>
</div>
<div className={`rounded-md p-2 text-center ${isGoldenTime ? 'bg-green-500/20' : 'bg-muted'}`}>
<div className="flex items-center justify-center gap-2">
<Sparkles className={`h-4 w-4 ${isGoldenTime ? 'text-yellow-400' : 'text-muted-foreground'}`} />
<span className={`text-sm font-semibold ${isGoldenTime ? 'text-green-400' : 'text-muted-foreground'}`}>
GOLDEN: {isGoldenTime ? 'YES' : 'NO'}
</span>
</div>
</div>
<div className="flex justify-center">
<Badge variant={canTrade ? "default" : "destructive"}>
{canTrade ? 'CAN TRADE' : 'NO TRADE'}
</Badge>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,68 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Brain, BarChart3 } from "lucide-react";
interface SignalCardProps {
title: string;
icon: "smc" | "ml";
signal: string;
confidence: number;
detail?: string;
buyProb?: number;
sellProb?: number;
}
export function SignalCard({ title, icon, signal, confidence, detail, buyProb, sellProb }: SignalCardProps) {
const getSignalColor = (sig: string) => {
if (sig === 'BUY') return 'text-green-500';
if (sig === 'SELL') return 'text-red-500';
if (sig === 'HOLD') return 'text-amber-500';
return 'text-muted-foreground';
};
const getProgressColor = (sig: string) => {
if (sig === 'BUY') return '[&>div]:bg-green-500';
if (sig === 'SELL') return '[&>div]:bg-red-500';
if (sig === 'HOLD') return '[&>div]:bg-amber-500';
return '';
};
return (
<Card className="bg-card/50 backdrop-blur">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
{icon === 'smc' ? <BarChart3 className="h-4 w-4" /> : <Brain className="h-4 w-4" />}
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center">
<span className={`text-2xl font-bold ${getSignalColor(signal)}`}>
{signal || 'NO SIGNAL'}
</span>
</div>
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-xs text-muted-foreground">Confidence</span>
<span className="text-xs font-semibold">{(confidence * 100).toFixed(0)}%</span>
</div>
<Progress value={confidence * 100} className={`h-1.5 ${getProgressColor(signal)}`} />
</div>
{detail && (
<p className="text-xs text-muted-foreground line-clamp-2">{detail}</p>
)}
{buyProb !== undefined && sellProb !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-green-500">Buy: {(buyProb * 100).toFixed(0)}%</span>
<span className="text-red-500">Sell: {(sellProb * 100).toFixed(0)}%</span>
</div>
)}
</CardContent>
</Card>
);
}
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}