1090 lines
56 KiB
TypeScript
1090 lines
56 KiB
TypeScript
import { useState, useRef, useEffect, useMemo } from 'react';
|
|
import type { ChangeEvent } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { tradingRuntime } from '../lib/runtime';
|
|
import { getPlatformAccessToken } from '../lib/authSession';
|
|
import { createRequestId } from '../../../shared/request-id.js';
|
|
import type { BotState } from '../hooks/useWebSocket';
|
|
import {
|
|
Send, X, Bot, User,
|
|
Check, Loader2,
|
|
Zap, Copy
|
|
} from 'lucide-react';
|
|
import { Button, Input, Select, Textarea } from './ui/Primitives';
|
|
import { cn } from '../lib/utils';
|
|
import { buildCreateExitPlanUrl, buildPlanDrillInUrl, buildPlansHomeUrl, buildSettingsSectionUrl } from '../views/tradePlansRoutes';
|
|
|
|
interface ChatMessage {
|
|
id: number;
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
profileData?: any;
|
|
action?: ChatAssistantAction;
|
|
insights?: string[];
|
|
nextActions?: string[];
|
|
quickLinks?: ChatQuickLink[];
|
|
timestamp: Date;
|
|
}
|
|
|
|
interface ChatControlProps {
|
|
profiles: any[];
|
|
botState: BotState;
|
|
onApplyProfile: (action: string, profile: any) => Promise<{ success: boolean; error?: string }>;
|
|
}
|
|
|
|
type ChatAssistantAction =
|
|
| 'create_profile'
|
|
| 'update_profile'
|
|
| 'recommend_profile_change'
|
|
| 'recommend_trade_plan'
|
|
| 'recommend_reconciliation_followup'
|
|
| 'review_recent_trades'
|
|
| 'explain'
|
|
| 'explain_position'
|
|
| 'explain_waiting'
|
|
| 'explain_blocker'
|
|
| 'summarize_reconciliation';
|
|
|
|
type ChatQuickLink =
|
|
| { kind: 'portfolio'; label: string; tradeId?: string; symbol?: string }
|
|
| { kind: 'plans'; label: string; symbol?: string; tradeId?: string; setupId?: string; mode?: 'sell' | 'view' }
|
|
| { kind: 'settings'; label: string; section?: 'Account' | 'Bot Config' | 'Admin Panel' };
|
|
|
|
export interface QuickAction {
|
|
label: string;
|
|
prompt: string;
|
|
}
|
|
|
|
export const BASE_QUICK_ACTIONS: QuickAction[] = [
|
|
{ label: 'Conservative Trader', prompt: 'Create a conservative BTC/ETH swing trader with $2000 capital and 1% risk' },
|
|
{ label: 'Aggressive Scalper', prompt: 'Build an aggressive scalper for SOL/DOGE with $500 capital, 3% risk and all rules enabled' },
|
|
{ label: 'Low Risk Profile', prompt: 'Create a low-risk profile that only trades BTC during London and NY sessions with $5000 capital' },
|
|
{ label: 'AI Momentum', prompt: 'Create a momentum strategy with AI analysis enabled, focusing on ETH/SOL with 2% risk' },
|
|
{ label: 'Explain holding', prompt: 'Explain my current open holding and what the bot is waiting for next.' },
|
|
{ label: 'Why no trade?', prompt: 'Why has no trade fired yet for my active profile? Explain what the bot is waiting for.' },
|
|
{ label: 'Explain blocker', prompt: 'Why is a trade or exit blocked right now? Explain the main blocker.' },
|
|
{ label: 'Recon summary', prompt: 'Summarize reconciliation health, stale orders, and any manual review risk right now.' },
|
|
{ label: 'Fix reconciliation', prompt: 'What should I do about reconciliation right now? Recommend the safest follow-up.' },
|
|
{ label: 'Review recent trades', prompt: 'Review my recent trades and tell me what to focus on next.' },
|
|
{ label: 'Manage live holding', prompt: 'Recommend the safest Trade Plan action for my current live holding.' },
|
|
{ label: 'What rules?', prompt: 'What rules should I use for a day trading strategy?' },
|
|
{ label: 'Modify existing', prompt: 'Show me my existing profiles and suggest improvements' },
|
|
];
|
|
|
|
export const cloneProfileDraft = (profileData: any) => ({
|
|
...profileData,
|
|
strategy_config: profileData?.strategy_config
|
|
? JSON.parse(JSON.stringify(profileData.strategy_config))
|
|
: undefined,
|
|
});
|
|
|
|
export const normalizeName = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
|
|
export const buildQuickActions = (profiles: any[]): QuickAction[] => {
|
|
const profileActions: QuickAction[] = [];
|
|
const highScalper = profiles.find((profileOption) => {
|
|
const normalized = normalizeName(String(profileOption?.name || ''));
|
|
return normalized.includes('highriskscalper') || normalized.includes('highscal');
|
|
});
|
|
const conservativeBag = profiles.find((profileOption) => {
|
|
const normalized = normalizeName(String(profileOption?.name || ''));
|
|
return normalized.includes('conservativebag');
|
|
});
|
|
|
|
if (highScalper?.name) {
|
|
profileActions.push({
|
|
label: `Tune ${highScalper.name}`,
|
|
prompt: `Review my current profile "${highScalper.name}" and recommend improved parameters before applying an update.`,
|
|
});
|
|
}
|
|
|
|
if (conservativeBag?.name) {
|
|
profileActions.push({
|
|
label: `Tune ${conservativeBag.name}`,
|
|
prompt: `Review my current profile "${conservativeBag.name}" and recommend improved parameters before applying an update.`,
|
|
});
|
|
}
|
|
|
|
return [...profileActions, ...BASE_QUICK_ACTIONS];
|
|
};
|
|
|
|
export const normalizeProfileForApply = (profileData: any) => ({
|
|
...profileData,
|
|
name: String(profileData?.name || 'AI Profile').trim(),
|
|
allocated_capital: Number(profileData?.allocated_capital || 0),
|
|
risk_per_trade_percent: Number(profileData?.risk_per_trade_percent || 0),
|
|
symbols: String(profileData?.symbols || '').trim(),
|
|
is_active: profileData?.is_active !== false,
|
|
});
|
|
|
|
const isProfileMutationAction = (action?: ChatAssistantAction): action is 'create_profile' | 'update_profile' | 'recommend_profile_change' =>
|
|
action === 'create_profile' || action === 'update_profile' || action === 'recommend_profile_change';
|
|
|
|
const formatChatError = (error: unknown) => {
|
|
const message = String((error as { message?: string })?.message || '').trim();
|
|
const lower = message.toLowerCase();
|
|
if (!message) {
|
|
return 'The assistant could not complete that request. Please try again.';
|
|
}
|
|
if (lower.includes('not authenticated') || lower.includes('unauthorized') || lower.includes('forbidden')) {
|
|
return 'Your session is not authorized for chat right now. Sign in again, then retry.';
|
|
}
|
|
if (lower.includes('failed to fetch') || lower.includes('network') || lower.includes('load failed')) {
|
|
return 'The chat service is temporarily unreachable. Check connectivity and retry.';
|
|
}
|
|
if (lower.includes('timeout')) {
|
|
return 'The chat request timed out before the assistant finished. Retry once the backend is responsive.';
|
|
}
|
|
return `The assistant could not complete that request: ${message}`;
|
|
};
|
|
|
|
const summarizeRuntimeContext = (botState: BotState) => ({
|
|
signalContexts: Object.entries(botState.symbols ?? {})
|
|
.flatMap(([symbol, symbolState]) =>
|
|
Object.entries(symbolState?.profileSignals || {}).map(([profileId, profileSignal]) => ({
|
|
symbol,
|
|
profileId,
|
|
profileName: profileSignal?.profileName,
|
|
signal: profileSignal?.signal,
|
|
passed: profileSignal?.passed,
|
|
reason: profileSignal?.reason,
|
|
executionStatus: profileSignal?.execution?.status,
|
|
executionCode: profileSignal?.execution?.code,
|
|
executionReason: profileSignal?.execution?.reason,
|
|
orderId: profileSignal?.execution?.orderId,
|
|
}))
|
|
)
|
|
.slice(0, 20),
|
|
positions: (botState.positions ?? []).slice(0, 10).map((position) => ({
|
|
symbol: position.symbol,
|
|
side: position.side,
|
|
size: position.size,
|
|
entryPrice: position.entryPrice,
|
|
currentPrice: position.currentPrice,
|
|
unrealizedPnl: position.unrealizedPnl,
|
|
unrealizedPnlPercent: position.unrealizedPnlPercent,
|
|
profileId: position.profileId,
|
|
profileName: position.profileName,
|
|
tradeId: position.tradeId,
|
|
stopLoss: position.stopLoss,
|
|
takeProfit: position.takeProfit,
|
|
})),
|
|
recentOrders: (botState.orders ?? [])
|
|
.slice()
|
|
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
|
.slice(0, 12)
|
|
.map((order) => ({
|
|
id: order.id,
|
|
symbol: order.symbol,
|
|
side: order.side,
|
|
qty: order.qty,
|
|
price: order.price,
|
|
status: order.status,
|
|
timestamp: order.timestamp,
|
|
profileId: order.profileId,
|
|
tradeId: order.trade_id,
|
|
action: order.action,
|
|
source: order.source,
|
|
})),
|
|
recentHistory: (botState.history ?? [])
|
|
.slice()
|
|
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
|
.slice(0, 12)
|
|
.map((trade) => ({
|
|
symbol: trade.symbol,
|
|
side: trade.side,
|
|
entryPrice: trade.entryPrice,
|
|
exitPrice: trade.exitPrice,
|
|
pnl: trade.pnl,
|
|
pnlPercent: trade.pnlPercent,
|
|
reason: trade.reason,
|
|
timestamp: trade.timestamp,
|
|
profileId: trade.profileId,
|
|
tradeId: trade.trade_id,
|
|
source: trade.source,
|
|
})),
|
|
orderFailures: (botState.orderFailures ?? [])
|
|
.slice()
|
|
.sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0))
|
|
.slice(0, 8)
|
|
.map((failure) => ({
|
|
symbol: failure.symbol,
|
|
side: failure.side,
|
|
qty: failure.qty,
|
|
reason: failure.reason,
|
|
profileId: failure.profileId,
|
|
tradeId: failure.tradeId,
|
|
timestamp: failure.timestamp,
|
|
})),
|
|
operationalEvents: (botState.operationalEvents ?? [])
|
|
.filter(Boolean)
|
|
.slice()
|
|
.sort((a, b) => Number(b?.timestamp || 0) - Number(a?.timestamp || 0))
|
|
.slice(0, 12)
|
|
.map((event) => ({
|
|
id: event?.id,
|
|
type: event?.type,
|
|
severity: event?.severity,
|
|
message: event?.message,
|
|
symbol: event?.symbol,
|
|
profileId: event?.profileId,
|
|
tradeId: event?.tradeId,
|
|
orderId: event?.orderId,
|
|
timestamp: event?.timestamp,
|
|
})),
|
|
accountSnapshot: botState.accountSnapshot
|
|
? {
|
|
buying_power: botState.accountSnapshot.buying_power,
|
|
cash: botState.accountSnapshot.cash,
|
|
currency: botState.accountSnapshot.currency,
|
|
timestamp: botState.accountSnapshot.timestamp,
|
|
}
|
|
: null,
|
|
health: botState.health
|
|
? {
|
|
tradingLoopHealthy: botState.health.tradingLoopHealthy,
|
|
orderSyncHealthy: botState.health.orderSyncHealthy,
|
|
reconciliationLoopHealthy: botState.health.reconciliationLoopHealthy,
|
|
reconciliationMismatchCount: botState.health.reconciliationMismatchCount,
|
|
reconciliationMissingFromExchange: botState.health.reconciliationMissingFromExchange,
|
|
reconciliationMissingInDb: botState.health.reconciliationMissingInDb,
|
|
reconciliationNoGoTrades: botState.health.reconciliationNoGoTrades,
|
|
reconciliationParityMismatchTrades: botState.health.reconciliationParityMismatchTrades ?? 0,
|
|
reconciliationParityQuarantinedTrades: botState.health.reconciliationParityQuarantinedTrades ?? 0,
|
|
reconciliationParityAutoClosedTrades: botState.health.reconciliationParityAutoClosedTrades ?? 0,
|
|
reconciliationIntegrityWatchdogTriggered: botState.health.reconciliationIntegrityWatchdogTriggered,
|
|
lockContentionCount: botState.health.lockContentionCount,
|
|
reconciliationLockContentionCount: botState.health.reconciliationLockContentionCount,
|
|
}
|
|
: null,
|
|
settings: botState.settings
|
|
? {
|
|
executionMode: botState.settings.executionMode,
|
|
totalCapital: botState.settings.totalCapital,
|
|
riskPerTrade: botState.settings.riskPerTrade,
|
|
maxOpenTrades: botState.settings.maxOpenTrades,
|
|
isAlgoEnabled: botState.settings.isAlgoEnabled,
|
|
}
|
|
: null,
|
|
});
|
|
|
|
// 3D Robot SVG Icon
|
|
const RobotIcon = ({ size = 32 }: { size?: number }) => (
|
|
<svg width={size} height={size} viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
{/* Antenna */}
|
|
<line x1="32" y1="6" x2="32" y2="14" stroke="var(--bl-success)" strokeWidth="2.5" strokeLinecap="round" />
|
|
<circle cx="32" cy="5" r="3" fill="var(--bl-success)" opacity="0.9">
|
|
<animate attributeName="opacity" values="0.5;1;0.5" dur="2s" repeatCount="indefinite" />
|
|
</circle>
|
|
{/* Head */}
|
|
<rect x="14" y="14" width="36" height="28" rx="8" fill="url(#headGrad)" stroke="var(--bl-success)" strokeWidth="1.5" opacity="0.95" />
|
|
{/* Eyes */}
|
|
<circle cx="24" cy="28" r="4.5" fill="var(--background)" />
|
|
<circle cx="24" cy="28" r="3" fill="var(--bl-success)" opacity="0.9">
|
|
<animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" />
|
|
</circle>
|
|
<circle cx="40" cy="28" r="4.5" fill="var(--background)" />
|
|
<circle cx="40" cy="28" r="3" fill="var(--bl-success)" opacity="0.9">
|
|
<animate attributeName="r" values="3;2.5;3" dur="3s" repeatCount="indefinite" />
|
|
</circle>
|
|
{/* Mouth */}
|
|
<rect x="24" y="35" width="16" height="3" rx="1.5" fill="var(--bl-success)" opacity="0.4" />
|
|
{/* Body */}
|
|
<rect x="18" y="44" width="28" height="14" rx="5" fill="url(#bodyGrad)" stroke="var(--bl-success)" strokeWidth="1" opacity="0.8" />
|
|
{/* Body detail */}
|
|
<circle cx="32" cy="51" r="3" fill="var(--bl-success)" opacity="0.3" />
|
|
{/* Arms */}
|
|
<rect x="8" y="46" width="8" height="10" rx="4" fill="url(#headGrad)" stroke="var(--bl-success)" strokeWidth="1" opacity="0.7" />
|
|
<rect x="48" y="46" width="8" height="10" rx="4" fill="url(#headGrad)" stroke="var(--bl-success)" strokeWidth="1" opacity="0.7" />
|
|
<defs>
|
|
<linearGradient id="headGrad" x1="14" y1="14" x2="50" y2="42" gradientUnits="userSpaceOnUse">
|
|
<stop stopColor="var(--card-elevated)" />
|
|
<stop offset="1" stopColor="var(--card)" />
|
|
</linearGradient>
|
|
<linearGradient id="bodyGrad" x1="18" y1="44" x2="46" y2="58" gradientUnits="userSpaceOnUse">
|
|
<stop stopColor="var(--card-elevated)" />
|
|
<stop offset="1" stopColor="var(--background)" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
);
|
|
|
|
export const ChatControl = ({ profiles, botState, onApplyProfile }: ChatControlProps) => {
|
|
const navigate = useNavigate();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [messages, setMessages] = useState<ChatMessage[]>([
|
|
{
|
|
id: 0,
|
|
role: 'assistant',
|
|
content: "Hi! I'm your trading assistant. Tell me what kind of strategy profile you'd like to create or modify, and I'll generate the configuration for you.\n\nTry: \"Create a conservative BTC scalper with $1000 capital\"",
|
|
timestamp: new Date(),
|
|
}
|
|
]);
|
|
const [input, setInput] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [appliedIds, setAppliedIds] = useState<Set<number>>(new Set());
|
|
const [cancelledIds, setCancelledIds] = useState<Set<number>>(new Set());
|
|
const [editingIds, setEditingIds] = useState<Set<number>>(new Set());
|
|
const [draftProfiles, setDraftProfiles] = useState<Record<number, any>>({});
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
const quickActions = useMemo(() => buildQuickActions(profiles), [profiles]);
|
|
|
|
const openQuickLink = (link: ChatQuickLink) => {
|
|
if (link.kind === 'portfolio') {
|
|
navigate('/portfolio');
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
if (link.kind === 'plans') {
|
|
if (link.setupId) {
|
|
navigate(buildPlanDrillInUrl(link.setupId));
|
|
} else if (link.mode === 'sell' && link.symbol) {
|
|
navigate(buildCreateExitPlanUrl(link.symbol, link.tradeId));
|
|
} else {
|
|
navigate(buildPlansHomeUrl());
|
|
}
|
|
setIsOpen(false);
|
|
return;
|
|
}
|
|
if (link.kind === 'settings') {
|
|
navigate(buildSettingsSectionUrl(link.section || 'Account'));
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
const openDraftEditor = (msg: ChatMessage) => {
|
|
if (!msg.profileData) return;
|
|
setDraftProfiles((prev) => ({ ...prev, [msg.id]: cloneProfileDraft(msg.profileData) }));
|
|
setEditingIds((prev) => new Set(prev).add(msg.id));
|
|
};
|
|
|
|
const closeDraftEditor = (msgId: number) => {
|
|
setEditingIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(msgId);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const resetDraft = (msg: ChatMessage) => {
|
|
if (!msg.profileData) return;
|
|
setDraftProfiles((prev) => ({ ...prev, [msg.id]: cloneProfileDraft(msg.profileData) }));
|
|
};
|
|
|
|
const updateDraftField = (msgId: number, field: string, value: any) => {
|
|
setDraftProfiles((prev) => ({
|
|
...prev,
|
|
[msgId]: {
|
|
...(prev[msgId] || {}),
|
|
[field]: value,
|
|
},
|
|
}));
|
|
};
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [messages]);
|
|
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setTimeout(() => inputRef.current?.focus(), 300);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const sendMessage = async (text?: string) => {
|
|
const msg = text || input.trim();
|
|
if (!msg || isLoading) return;
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: Date.now(),
|
|
role: 'user',
|
|
content: msg,
|
|
timestamp: new Date(),
|
|
};
|
|
setMessages(prev => [...prev, userMsg]);
|
|
setInput('');
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const apiUrl = tradingRuntime.tradingApiUrl;
|
|
const accessToken = await getPlatformAccessToken();
|
|
const res = await fetch(`${apiUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${accessToken}`,
|
|
'x-request-id': createRequestId('web-chat')
|
|
},
|
|
body: JSON.stringify({
|
|
message: msg,
|
|
context: {
|
|
profiles: profiles.map(p => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
allocated_capital: p.allocated_capital,
|
|
risk_per_trade_percent: p.risk_per_trade_percent,
|
|
symbols: p.symbols,
|
|
is_active: p.is_active,
|
|
strategy_config: p.strategy_config,
|
|
})),
|
|
runtime: summarizeRuntimeContext(botState),
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || 'Chat request failed');
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
const assistantMsg: ChatMessage = {
|
|
id: Date.now() + 1,
|
|
role: 'assistant',
|
|
content: data.summary || data.reasoning || 'Profile configuration generated.',
|
|
profileData: data.profile || null,
|
|
action: data.action as ChatAssistantAction | undefined,
|
|
insights: Array.isArray(data.insights) ? data.insights.map((entry: unknown) => String(entry)) : undefined,
|
|
nextActions: Array.isArray(data.nextActions) ? data.nextActions.map((entry: unknown) => String(entry)) : undefined,
|
|
quickLinks: Array.isArray(data.quickLinks) ? data.quickLinks as ChatQuickLink[] : undefined,
|
|
timestamp: new Date(),
|
|
};
|
|
|
|
if (data.reasoning && data.summary) {
|
|
assistantMsg.content = `${data.summary}\n\n${data.reasoning}`;
|
|
}
|
|
|
|
setMessages(prev => [...prev, assistantMsg]);
|
|
} catch (err: any) {
|
|
setMessages(prev => [...prev, {
|
|
id: Date.now() + 1,
|
|
role: 'assistant',
|
|
content: formatChatError(err),
|
|
timestamp: new Date(),
|
|
}]);
|
|
}
|
|
|
|
setIsLoading(false);
|
|
};
|
|
|
|
const handleApply = async (msg: ChatMessage) => {
|
|
if (msg.profileData && isProfileMutationAction(msg.action)) {
|
|
const activeDraft = draftProfiles[msg.id] || msg.profileData;
|
|
const payload = normalizeProfileForApply(activeDraft);
|
|
const applyAction = msg.action === 'recommend_profile_change'
|
|
? (payload.id ? 'update_profile' : 'create_profile')
|
|
: msg.action;
|
|
const result = await onApplyProfile(applyAction, payload);
|
|
if (result.success) {
|
|
setAppliedIds(prev => new Set(prev).add(msg.id));
|
|
closeDraftEditor(msg.id);
|
|
setMessages(prev => [...prev, {
|
|
id: Date.now(),
|
|
role: 'assistant',
|
|
content: applyAction === 'create_profile'
|
|
? `Profile "${payload.name}" has been created successfully! It's now visible in Strategy Clusters and the bot will pick it up on next sync (~60s).`
|
|
: `Profile "${payload.name}" has been updated successfully!`,
|
|
timestamp: new Date(),
|
|
}]);
|
|
} else {
|
|
setMessages(prev => [...prev, {
|
|
id: Date.now(),
|
|
role: 'assistant',
|
|
content: `Failed to ${applyAction === 'create_profile' ? 'create' : 'update'} profile: ${result.error || 'Unknown error'}. Please try again or check your permissions.`,
|
|
timestamp: new Date(),
|
|
}]);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleCancel = (msgId: number) => {
|
|
setCancelledIds(prev => new Set(prev).add(msgId));
|
|
closeDraftEditor(msgId);
|
|
setMessages(prev => [...prev, {
|
|
id: Date.now(),
|
|
role: 'assistant',
|
|
content: 'Profile creation cancelled. You can ask me to create a different one or modify the parameters.',
|
|
timestamp: new Date(),
|
|
}]);
|
|
};
|
|
|
|
const copyJson = (data: any) => {
|
|
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendMessage();
|
|
}
|
|
};
|
|
|
|
const assistantTint = 'var(--accent-soft)';
|
|
const panelStyle: React.CSSProperties = {
|
|
background: 'var(--card)',
|
|
border: '1px solid var(--border)',
|
|
boxShadow: 'var(--card-shadow)',
|
|
};
|
|
const inputStyle: React.CSSProperties = {
|
|
background: 'var(--input)',
|
|
border: '1px solid var(--border)',
|
|
color: 'var(--foreground)',
|
|
boxShadow: 'none',
|
|
caretColor: 'var(--ring)',
|
|
};
|
|
|
|
// Floating robot button - bottom right corner (portaled to body to avoid parent CSS issues)
|
|
if (!isOpen) {
|
|
return createPortal(
|
|
<Button
|
|
type="button"
|
|
onClick={() => setIsOpen(true)}
|
|
variant="ghost"
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: '24px',
|
|
right: '24px',
|
|
zIndex: 99999,
|
|
cursor: 'pointer',
|
|
background: 'none',
|
|
border: 'none',
|
|
padding: 0,
|
|
animation: 'robotFloat 3s ease-in-out infinite',
|
|
}}
|
|
className="group"
|
|
>
|
|
<div style={{ position: 'relative' }}>
|
|
{/* Glow ring */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
inset: '-8px',
|
|
borderRadius: '50%',
|
|
opacity: 0.4,
|
|
background: 'radial-gradient(circle, color-mix(in oklab, var(--ring) 30%, transparent), transparent 70%)',
|
|
transition: 'opacity 0.3s',
|
|
}} />
|
|
{/* Robot container */}
|
|
<div style={{
|
|
position: 'relative',
|
|
width: '56px',
|
|
height: '56px',
|
|
borderRadius: '16px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'var(--card)',
|
|
border: '1.5px solid var(--border)',
|
|
boxShadow: 'var(--card-shadow), 0 0 20px color-mix(in oklab, var(--ring) 16%, transparent)',
|
|
transition: 'transform 0.2s',
|
|
}}>
|
|
<RobotIcon size={34} />
|
|
</div>
|
|
{/* Pulse dot */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '-2px',
|
|
right: '-2px',
|
|
width: '14px',
|
|
height: '14px',
|
|
borderRadius: '50%',
|
|
background: 'var(--primary)',
|
|
border: '2px solid var(--card)',
|
|
boxShadow: '0 0 8px color-mix(in oklab, var(--primary) 45%, transparent)',
|
|
animation: 'pulseDot 2s ease-in-out infinite',
|
|
}} />
|
|
</div>
|
|
<style>{`
|
|
@keyframes robotFloat {
|
|
0%, 100% { transform: translateY(0px); }
|
|
50% { transform: translateY(-4px); }
|
|
}
|
|
@keyframes pulseDot {
|
|
0%, 100% { transform: scale(1); opacity: 1; }
|
|
50% { transform: scale(0.8); opacity: 0.6; }
|
|
}
|
|
`}</style>
|
|
</Button>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
return createPortal(
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
onClick={() => setIsOpen(false)}
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
zIndex: 999998,
|
|
background: 'color-mix(in oklab, var(--background) 45%, transparent)',
|
|
backdropFilter: 'blur(6px)',
|
|
animation: 'fadeIn 0.15s ease-out',
|
|
}}
|
|
/>
|
|
<div style={{
|
|
position: 'fixed',
|
|
bottom: '24px',
|
|
right: '24px',
|
|
zIndex: 999999,
|
|
width: '460px',
|
|
maxWidth: 'calc(100vw - 48px)',
|
|
height: '640px',
|
|
maxHeight: 'calc(100vh - 48px)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
borderRadius: '20px',
|
|
overflow: 'hidden',
|
|
animation: 'chatSlideUp 0.25s ease-out',
|
|
...panelStyle,
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{
|
|
background: 'var(--hero-gradient)',
|
|
borderBottom: '1px solid var(--border)',
|
|
padding: '14px 18px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{
|
|
background: 'var(--card)',
|
|
border: '1px solid var(--border)',
|
|
boxShadow: 'var(--card-shadow)',
|
|
}}>
|
|
<RobotIcon size={26} />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-[13px] font-bold text-[var(--foreground)] leading-none">AI Trading Copilot</h3>
|
|
<p className="mt-1 text-[10px] text-[var(--muted-foreground)]">Create profiles, explain holdings, and diagnose blockers</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={() => setIsOpen(false)}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 w-8 rounded-lg"
|
|
>
|
|
<X size={14} />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4" style={{
|
|
background: 'var(--background)',
|
|
scrollbarWidth: 'thin',
|
|
scrollbarColor: 'var(--border) transparent',
|
|
}}>
|
|
{messages.map(msg => (
|
|
<div key={msg.id} className={`flex gap-2.5 ${msg.role === 'user' ? 'flex-row-reverse' : ''}`}>
|
|
{/* Avatar */}
|
|
<div className={cn('shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border', msg.role === 'user' ? 'bg-blue-500/12 border-blue-500/20' : '')}
|
|
style={msg.role === 'user' ? undefined : { background: assistantTint, borderColor: 'var(--border)' }}>
|
|
{msg.role === 'user'
|
|
? <User size={12} className="text-blue-400" />
|
|
: <Bot size={12} className="text-[var(--primary)]" />
|
|
}
|
|
</div>
|
|
|
|
{/* Bubble */}
|
|
<div className={`max-w-[85%] ${msg.role === 'user' ? 'text-right' : ''}`}>
|
|
<div className="rounded-xl px-3.5 py-2.5 text-[12px] leading-relaxed" style={{
|
|
background: msg.role === 'user'
|
|
? 'linear-gradient(135deg, var(--bl-info-muted), color-mix(in oklab, var(--bl-info) 8%, transparent))'
|
|
: 'var(--card)',
|
|
border: `1px solid ${msg.role === 'user' ? 'var(--bl-info-border, var(--bl-info))' : 'var(--border)'}`,
|
|
color: msg.role === 'user' ? 'var(--bl-info)' : 'var(--foreground)',
|
|
whiteSpace: 'pre-wrap',
|
|
}}>
|
|
{msg.content}
|
|
</div>
|
|
|
|
{msg.nextActions && msg.nextActions.length > 0 ? (
|
|
<div className="mt-2 rounded-xl px-3 py-2.5" style={{
|
|
background: 'var(--card-elevated)',
|
|
border: '1px solid var(--border)',
|
|
}}>
|
|
<div className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
|
|
Suggested next actions
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
{msg.nextActions.map((nextAction, index) => (
|
|
<div
|
|
key={`${msg.id}-next-${index}`}
|
|
className="rounded-lg px-2.5 py-2 text-[11px] leading-relaxed"
|
|
style={{
|
|
background: 'var(--accent-soft)',
|
|
color: 'var(--foreground)',
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
{index + 1}. {nextAction}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{msg.insights && msg.insights.length > 0 ? (
|
|
<div className="mt-2 rounded-xl px-3 py-2.5" style={{
|
|
background: 'var(--card-elevated)',
|
|
border: '1px solid var(--border)',
|
|
}}>
|
|
<div className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
|
|
Key facts
|
|
</div>
|
|
<div className="flex flex-col gap-1.5">
|
|
{msg.insights.map((insight, index) => (
|
|
<div
|
|
key={`${msg.id}-insight-${index}`}
|
|
className="rounded-lg px-2.5 py-2 text-[11px] leading-relaxed"
|
|
style={{
|
|
background: 'var(--accent-soft)',
|
|
color: 'var(--foreground)',
|
|
border: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
{insight}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{msg.quickLinks && msg.quickLinks.length > 0 ? (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{msg.quickLinks.map((link, index) => (
|
|
<Button
|
|
key={`${msg.id}-link-${index}`}
|
|
type="button"
|
|
onClick={() => openQuickLink(link)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 rounded-full px-3 text-[11px]"
|
|
>
|
|
{link.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Profile preview card */}
|
|
{msg.profileData && isProfileMutationAction(msg.action) && (() => {
|
|
const activeProfileData = draftProfiles[msg.id] || msg.profileData;
|
|
const isEditing = editingIds.has(msg.id);
|
|
const activeRules = Array.isArray(activeProfileData?.strategy_config?.rules)
|
|
? activeProfileData.strategy_config.rules.filter((r: any) => r?.enabled).length
|
|
: 0;
|
|
|
|
return (
|
|
<div className="mt-2 rounded-xl overflow-hidden" style={{
|
|
background: 'var(--card-elevated)',
|
|
border: '1px solid var(--border)',
|
|
boxShadow: 'var(--card-shadow)',
|
|
}}>
|
|
<div className="px-3.5 py-2 flex items-center justify-between" style={{
|
|
background: 'var(--accent-soft)',
|
|
borderBottom: '1px solid var(--border)',
|
|
}}>
|
|
<div className="flex items-center gap-2">
|
|
<Zap size={10} className="text-[var(--primary)]" />
|
|
<span className="text-[10px] font-bold text-[var(--muted-foreground)] uppercase tracking-wider">
|
|
{msg.action === 'create_profile'
|
|
? 'New Profile'
|
|
: msg.action === 'recommend_profile_change'
|
|
? 'Suggested Profile Change'
|
|
: 'Update Profile'}
|
|
</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
onClick={() => copyJson(activeProfileData)}
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
|
title="Copy JSON"
|
|
>
|
|
<Copy size={11} />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="px-3.5 py-2.5 space-y-1.5">
|
|
<div className="flex justify-between">
|
|
<span className="text-[10px] text-[var(--muted-foreground)]">Name</span>
|
|
<span className="text-[11px] font-bold text-[var(--foreground)]">{activeProfileData?.name}</span>
|
|
</div>
|
|
{activeProfileData?.allocated_capital ? (
|
|
<div className="flex justify-between">
|
|
<span className="text-[10px] text-[var(--muted-foreground)]">Capital</span>
|
|
<span className="text-[11px] font-bold text-blue-400 font-mono">${activeProfileData.allocated_capital}</span>
|
|
</div>
|
|
) : null}
|
|
{activeProfileData?.risk_per_trade_percent ? (
|
|
<div className="flex justify-between">
|
|
<span className="text-[10px] text-[var(--muted-foreground)]">Risk / Trade</span>
|
|
<span className="text-[11px] font-bold text-amber-400 font-mono">{activeProfileData.risk_per_trade_percent}%</span>
|
|
</div>
|
|
) : null}
|
|
{activeProfileData?.symbols ? (
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-[var(--muted-foreground)]">Symbols</span>
|
|
<span className="text-[10px] font-mono text-[var(--foreground)]">{activeProfileData.symbols}</span>
|
|
</div>
|
|
) : null}
|
|
{activeRules > 0 ? (
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-[10px] text-[var(--muted-foreground)]">Rules</span>
|
|
<span className="text-[10px] font-mono text-[var(--primary)]">
|
|
{activeRules} active
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<div className="px-3.5 pb-3 space-y-2">
|
|
<div className="text-[10px] text-[var(--muted-foreground)] uppercase tracking-wider font-bold">Edit Parameters Before Apply</div>
|
|
<Input
|
|
value={activeProfileData?.name || ''}
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => updateDraftField(msg.id, 'name', e.target.value)}
|
|
placeholder="Profile Name"
|
|
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={activeProfileData?.allocated_capital ?? ''}
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => updateDraftField(msg.id, 'allocated_capital', e.target.value)}
|
|
placeholder="Capital"
|
|
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={activeProfileData?.risk_per_trade_percent ?? ''}
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => updateDraftField(msg.id, 'risk_per_trade_percent', e.target.value)}
|
|
placeholder="Risk %"
|
|
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
</div>
|
|
<Input
|
|
value={activeProfileData?.symbols || ''}
|
|
onChange={(e: ChangeEvent<HTMLInputElement>) => updateDraftField(msg.id, 'symbols', e.target.value)}
|
|
placeholder="Symbols (e.g. BTC/USDT,ETH/USDT)"
|
|
className="w-full rounded-lg px-2.5 py-1.5 text-[11px] outline-none"
|
|
style={inputStyle}
|
|
/>
|
|
<div className="flex items-center justify-between rounded-lg px-2.5 py-1.5" style={inputStyle}>
|
|
<span className="text-[10px] text-[var(--muted-foreground)] uppercase tracking-wider">Auto Trading</span>
|
|
<Select
|
|
value={activeProfileData?.is_active === false ? 'false' : 'true'}
|
|
onChange={(e: ChangeEvent<HTMLSelectElement>) => updateDraftField(msg.id, 'is_active', e.target.value === 'true')}
|
|
className="rounded px-2 py-1 text-[10px] outline-none"
|
|
style={inputStyle}
|
|
options={[
|
|
{ value: 'true', label: 'Active' },
|
|
{ value: 'false', label: 'Paused' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
onClick={() => resetDraft(msg)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 px-2.5 text-[9px] uppercase tracking-wider"
|
|
>
|
|
Reset
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{!appliedIds.has(msg.id) && !cancelledIds.has(msg.id) ? (
|
|
<div className="flex" style={{ borderTop: '1px solid var(--border)' }}>
|
|
<Button
|
|
type="button"
|
|
onClick={() => handleCancel(msg.id)}
|
|
variant="ghost"
|
|
className="flex-1 py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:bg-white/[0.03]"
|
|
style={{
|
|
color: 'var(--destructive)',
|
|
borderRight: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
<X size={11} />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => isEditing ? closeDraftEditor(msg.id) : openDraftEditor(msg)}
|
|
variant="ghost"
|
|
className="flex-1 py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:bg-white/[0.03]"
|
|
style={{
|
|
color: 'var(--bl-warning)',
|
|
borderRight: '1px solid var(--border)',
|
|
}}
|
|
>
|
|
<Copy size={11} />
|
|
{isEditing ? 'Done Editing' : 'Edit Params'}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => handleApply(msg)}
|
|
variant="ghost"
|
|
className="flex-[2] py-2.5 flex items-center justify-center gap-1.5 text-[11px] font-bold transition-all hover:brightness-110"
|
|
style={{
|
|
background: 'var(--accent-soft)',
|
|
color: 'var(--primary)',
|
|
}}
|
|
>
|
|
<Zap size={11} />
|
|
Apply to Dashboard
|
|
</Button>
|
|
</div>
|
|
) : cancelledIds.has(msg.id) ? (
|
|
<div className="w-full py-2 flex items-center justify-center gap-1.5 text-[10px] font-semibold" style={{
|
|
borderTop: '1px solid var(--border)',
|
|
color: 'var(--muted-foreground)',
|
|
}}>
|
|
<X size={10} />
|
|
Cancelled
|
|
</div>
|
|
) : (
|
|
<div className="w-full py-2.5 flex items-center justify-center gap-2 text-[11px] font-bold" style={{
|
|
background: 'var(--accent-soft)',
|
|
borderTop: '1px solid var(--border)',
|
|
color: 'var(--primary)',
|
|
}}>
|
|
<Check size={12} />
|
|
Applied
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
<span className="mt-1 block text-[9px] text-[var(--muted-foreground)]">
|
|
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Suggested quick actions - shown when only welcome message exists */}
|
|
{messages.length <= 1 && !isLoading && (
|
|
<div className="mt-2">
|
|
<p style={{ fontSize: '9px', color: 'var(--muted-foreground)', textTransform: 'uppercase', letterSpacing: '0.1em', fontWeight: 700, marginBottom: '10px', paddingLeft: '4px' }}>Quick Actions</p>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{quickActions.map((action, i) => (
|
|
<Button
|
|
type="button"
|
|
key={i}
|
|
onClick={() => sendMessage(action.prompt)}
|
|
variant="ghost"
|
|
className="text-left px-3.5 py-3 rounded-xl transition-all"
|
|
style={{
|
|
background: 'var(--card)',
|
|
border: '1px solid var(--border)',
|
|
cursor: 'pointer',
|
|
}}
|
|
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.currentTarget.style.borderColor = 'var(--ring)';
|
|
e.currentTarget.style.background = 'var(--accent-soft)';
|
|
}}
|
|
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.currentTarget.style.borderColor = 'var(--border)';
|
|
e.currentTarget.style.background = 'var(--card)';
|
|
}}
|
|
>
|
|
<span style={{ fontSize: '13px', display: 'block', marginBottom: '3px', color: 'var(--foreground)' }}>{action.label}</span>
|
|
<span style={{ fontSize: '10px', color: 'var(--muted-foreground)', lineHeight: '1.4', display: 'block' }}>{action.prompt.slice(0, 55)}...</span>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="flex gap-2.5">
|
|
<div className="flex h-7 w-7 items-center justify-center rounded-lg border" style={{ background: assistantTint, borderColor: 'var(--border)' }}>
|
|
<Bot size={12} className="text-[var(--primary)]" />
|
|
</div>
|
|
<div className="rounded-xl px-3.5 py-2.5 flex items-center gap-2" style={{
|
|
background: 'var(--card)',
|
|
border: '1px solid var(--border)',
|
|
}}>
|
|
<Loader2 size={12} className="animate-spin text-[var(--primary)]" />
|
|
<span className="text-[11px] text-[var(--muted-foreground)]">Thinking through your trading context...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input area */}
|
|
<div style={{
|
|
background: 'var(--card)',
|
|
borderTop: '1px solid var(--border)',
|
|
padding: '14px 16px',
|
|
}}>
|
|
<div className="flex items-end gap-2.5">
|
|
<div className="flex-1 relative">
|
|
<Textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Ask for a profile, holding explanation, or reconciliation help..."
|
|
disabled={isLoading}
|
|
rows={2}
|
|
className="w-full rounded-xl py-3 pl-4 pr-12 outline-none disabled:opacity-50 transition-all resize-none"
|
|
style={{ ...inputStyle, lineHeight: '1.5', fontFamily: 'inherit', fontSize: '13px' }}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={() => sendMessage()}
|
|
disabled={!input.trim() || isLoading}
|
|
variant="ghost"
|
|
className="absolute right-2.5 bottom-2.5 w-8 h-8 rounded-lg flex items-center justify-center transition-all disabled:opacity-20 hover:scale-105"
|
|
style={{
|
|
background: input.trim() ? 'var(--primary)' : 'var(--accent-soft)',
|
|
color: input.trim() ? 'var(--primary-foreground)' : 'var(--muted-foreground)',
|
|
boxShadow: input.trim() ? '0 2px 10px color-mix(in oklab, var(--primary) 25%, transparent)' : 'none',
|
|
}}
|
|
>
|
|
<Send size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="mt-1.5 ml-1 text-[9px] text-[var(--muted-foreground)]">Enter to send · Shift+Enter new line</p>
|
|
</div>
|
|
|
|
<style>{`
|
|
@keyframes chatSlideUp {
|
|
from { transform: translateY(20px) scale(0.97); opacity: 0; }
|
|
to { transform: translateY(0) scale(1); opacity: 1; }
|
|
}
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
</>,
|
|
document.body
|
|
);
|
|
};
|