feat: add user onboarding tour and notification UI
- Integrate react-joyride for user onboarding tours - Add sonner for enhanced toast notifications - Update AI analysis service to support preferred trading styles - Refactor navbar layout and downgrade firebase dependencies for stability
This commit is contained in:
+2
-1
@@ -100,7 +100,8 @@ service cloud.firestore {
|
||||
&& exists(/databases/$(database)/documents/users/$(request.auth.uid))
|
||||
&& get(/databases/$(database)/documents/users/$(request.auth.uid)).data.isApproved == true;
|
||||
|
||||
allow update, delete: if isDbAdmin();
|
||||
allow update: if isDbAdmin() || (isSignedIn() && resource.data.userId == request.auth.uid && incoming().diff(existing()).affectedKeys().hasOnly(['result']));
|
||||
allow delete: if isDbAdmin();
|
||||
}
|
||||
|
||||
// --- Testimonials Collection ---
|
||||
|
||||
Generated
+525
-358
File diff suppressed because it is too large
Load Diff
@@ -33,9 +33,11 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-joyride": "^3.1.0",
|
||||
"react-onesignal": "^3.5.2",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^6.2.0"
|
||||
|
||||
@@ -25,15 +25,58 @@ async function startServer() {
|
||||
// --- BACKGROUND WORKER NODE (CRON JOB) ---
|
||||
function getYfSymbol(pair: string): string {
|
||||
if (pair.includes('XAU')) return 'GC=F';
|
||||
if (pair.includes('XAG')) return 'SI=F';
|
||||
if (pair.includes('US100')) return 'NQ=F';
|
||||
if (pair.includes('US30')) return 'YM=F';
|
||||
if (pair.includes('BTC')) return 'BTC-USD';
|
||||
if (pair.includes('ETH')) return 'ETH-USD';
|
||||
|
||||
// Clean punctuation and common broker suffixes
|
||||
const cleanPair = pair.toString().replace('/', '').replace(/\.M$/, '').replace(/\.std$/, '');
|
||||
return cleanPair + '=X';
|
||||
}
|
||||
|
||||
async function getCurrentPriceProxy(symbol: string): Promise<number | null> {
|
||||
const symStr = symbol.trim().toUpperCase();
|
||||
if (symStr.includes('INDEX') || symStr.includes('BOOM') || symStr.includes('CRASH') || symStr.includes('VIX') || symStr.includes('VOLATILITY') || symStr.includes('STEP')) {
|
||||
return null; // Synthetic indices handled separately or simulated
|
||||
}
|
||||
|
||||
if (process.env.TWELVE_DATA_API_KEY) {
|
||||
try {
|
||||
let twelveSymbol = symStr;
|
||||
if (twelveSymbol === 'GOLD' || twelveSymbol === 'OURO') twelveSymbol = 'XAU/USD';
|
||||
if (twelveSymbol === 'SILVER' || twelveSymbol === 'PRATA') twelveSymbol = 'XAG/USD';
|
||||
const res = await axios.get(`https://api.twelvedata.com/price?symbol=${twelveSymbol}&apikey=${process.env.TWELVE_DATA_API_KEY}`);
|
||||
if (res.data && res.data.price) return parseFloat(res.data.price);
|
||||
} catch (e: any) {
|
||||
// Fallback silently if TwelveData is rate-limited or fails
|
||||
}
|
||||
}
|
||||
|
||||
const isCrypto = symStr.includes('BTC') || symStr.includes('ETH') || symStr.includes('USDT') || symStr.includes('BNB');
|
||||
if (isCrypto) {
|
||||
try {
|
||||
const binanceSymbol = symStr.replace(/[^A-Z0-9]/g, '');
|
||||
const res = await axios.get(`https://api.binance.com/api/v3/ticker/price?symbol=${binanceSymbol}`);
|
||||
if (res.data && res.data.price) return parseFloat(res.data.price);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
try {
|
||||
let symbolYF = getYfSymbol(symStr);
|
||||
const yfRes = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${symbolYF}?interval=1m&range=1d`, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }
|
||||
});
|
||||
const result = yfRes.data.chart.result[0];
|
||||
return parseFloat(result.meta.regularMarketPrice);
|
||||
} catch(e: any) {
|
||||
console.warn("Yahoo Finance error:", e.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const sendTelegramAlertServer = async (text: string, botToken: string, chatId: string, replyToMessageId?: number) => {
|
||||
try {
|
||||
const payload: any = {
|
||||
@@ -63,7 +106,8 @@ const sendTelegramAlertServer = async (text: string, botToken: string, chatId: s
|
||||
const signal = docSnap.data();
|
||||
let currentPrice = null;
|
||||
try {
|
||||
if (signal.pair.includes('Index')) {
|
||||
const symStr = signal.pair.toUpperCase();
|
||||
if (symStr.includes('INDEX') || symStr.includes('BOOM') || symStr.includes('CRASH') || symStr.includes('VIX') || symStr.includes('VOLATILITY') || symStr.includes('STEP')) {
|
||||
const tp = parseFloat(signal.takeProfit);
|
||||
const sl = parseFloat(signal.stopLoss);
|
||||
// Simulamos um fecho aleatório para os índices para testes rápidos
|
||||
@@ -73,13 +117,10 @@ const sendTelegramAlertServer = async (text: string, botToken: string, chatId: s
|
||||
currentPrice = (tp + sl) / 2; // price stays in the middle
|
||||
}
|
||||
} else {
|
||||
let symbolYF = getYfSymbol(signal.pair);
|
||||
const yfRes = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${symbolYF}?interval=1m&range=1d`);
|
||||
const result = yfRes.data.chart.result[0];
|
||||
currentPrice = parseFloat(result.meta.regularMarketPrice);
|
||||
currentPrice = await getCurrentPriceProxy(symStr);
|
||||
}
|
||||
} catch(e: any) {
|
||||
console.error("Yahoo Finance data fetch error:", e.message);
|
||||
console.error("Proxy price fetch error:", e.message);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -751,7 +792,29 @@ const sendTelegramAlertServer = async (text: string, botToken: string, chatId: s
|
||||
tradeResult = await connection.createMarketSellOrder(sym, volume, 0, 0, { comment: 'TEST SMART ENTRY' });
|
||||
}
|
||||
|
||||
res.json({ success: true, tradeResult });
|
||||
let parsedResult: any = {};
|
||||
if (tradeResult) {
|
||||
try {
|
||||
// Extract plain properties to avoid circular references from MetaApi objects
|
||||
parsedResult = {
|
||||
orderId: tradeResult.orderId,
|
||||
positionId: tradeResult.positionId,
|
||||
volume: tradeResult.volume,
|
||||
price: tradeResult.price,
|
||||
type: tradeResult.type,
|
||||
comment: tradeResult.comment,
|
||||
done: tradeResult.done,
|
||||
// For Deriv API fallback
|
||||
buy: tradeResult.buy?.transaction_id,
|
||||
sell: tradeResult.sell?.transaction_id,
|
||||
error: tradeResult.error ? String(tradeResult.error) : undefined
|
||||
};
|
||||
} catch(e) {
|
||||
parsedResult = { message: "Trade executed, but could not parse result due to circular references." };
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, tradeResult: parsedResult });
|
||||
} catch(e: any) {
|
||||
const errString = e.message || e.toString();
|
||||
if (errString.toLowerCase().includes("permission") || errString.toLowerCase().includes("insufficient")) {
|
||||
@@ -768,16 +831,19 @@ const sendTelegramAlertServer = async (text: string, botToken: string, chatId: s
|
||||
app.get("/api/twelve/quote", async (req, res) => {
|
||||
const symbol = req.query.symbol || "EUR/USD";
|
||||
try {
|
||||
if (symbol.toString().includes('Index') || symbol.toString().includes('BOOM') || symbol.toString().includes('CRASH')) {
|
||||
const symStr = symbol.toString().toUpperCase();
|
||||
if (symStr.includes('INDEX') || symStr.includes('BOOM') || symStr.includes('CRASH') || symStr.includes('VIX') || symStr.includes('VOLATILITY') || symStr.includes('STEP')) {
|
||||
return res.json({ close: "15000" });
|
||||
}
|
||||
let symbolYF = getYfSymbol(symbol.toString());
|
||||
const response = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${symbolYF}?interval=1d&range=1d`);
|
||||
const price = response.data.chart.result[0].meta.regularMarketPrice;
|
||||
res.json({ close: price.toString() });
|
||||
|
||||
const price = await getCurrentPriceProxy(symbol.toString());
|
||||
if (price !== null) {
|
||||
res.json({ close: price.toString() });
|
||||
} else {
|
||||
res.json({ close: "1.000" });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Error fetching proxy quote for ${symbol}:`, error.message);
|
||||
// Fallback
|
||||
res.json({ close: "1.000" });
|
||||
}
|
||||
});
|
||||
@@ -785,13 +851,43 @@ const sendTelegramAlertServer = async (text: string, botToken: string, chatId: s
|
||||
// Proxy for history mapped to Twelve format
|
||||
app.get("/api/twelve/history", async (req, res) => {
|
||||
const symbol = req.query.symbol as string;
|
||||
let symbolYF = "";
|
||||
try {
|
||||
if (symbol.toString().includes('Index') || symbol.toString().includes('BOOM') || symbol.toString().includes('CRASH')) {
|
||||
const symStr = symbol.toString().toUpperCase();
|
||||
if (symStr.includes('INDEX') || symStr.includes('BOOM') || symStr.includes('CRASH') || symStr.includes('VIX') || symStr.includes('VOLATILITY') || symStr.includes('STEP')) {
|
||||
return res.json({ values: [] });
|
||||
}
|
||||
symbolYF = getYfSymbol(symbol.toString());
|
||||
const response = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${symbolYF}?interval=15m&range=5d`);
|
||||
|
||||
if (process.env.TWELVE_DATA_API_KEY) {
|
||||
try {
|
||||
let twelveSymbol = symStr;
|
||||
if (twelveSymbol === 'GOLD' || twelveSymbol === 'OURO') twelveSymbol = 'XAU/USD';
|
||||
if (twelveSymbol === 'SILVER' || twelveSymbol === 'PRATA') twelveSymbol = 'XAG/USD';
|
||||
const tkres = await axios.get(`https://api.twelvedata.com/time_series?symbol=${twelveSymbol}&interval=15min&apikey=${process.env.TWELVE_DATA_API_KEY}`);
|
||||
if (tkres.data && tkres.data.values) {
|
||||
return res.json({ values: tkres.data.values });
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
const isCrypto = symStr.includes('BTC') || symStr.includes('ETH') || symStr.includes('USDT') || symStr.includes('BNB');
|
||||
if (isCrypto) {
|
||||
try {
|
||||
const binanceSymbol = symStr.replace(/[^A-Z0-9]/g, '');
|
||||
const bRes = await axios.get(`https://api.binance.com/api/v3/klines?symbol=${binanceSymbol}&interval=15m&limit=200`);
|
||||
if (bRes.data && Array.isArray(bRes.data)) {
|
||||
const values = bRes.data.map((c: any) => ({
|
||||
datetime: new Date(c[0]).toISOString().replace('T', ' ').substring(0, 19),
|
||||
open: c[1], high: c[2], low: c[3], close: c[4], volume: c[5]
|
||||
})).reverse();
|
||||
return res.json({ values });
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
let symbolYF = getYfSymbol(symbol.toString());
|
||||
const response = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${symbolYF}?interval=15m&range=5d`, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }
|
||||
});
|
||||
const result = response.data.chart.result[0];
|
||||
const quote = result.indicators.quote[0];
|
||||
const timestamps = result.timestamp;
|
||||
@@ -801,7 +897,7 @@ const sendTelegramAlertServer = async (text: string, botToken: string, chatId: s
|
||||
for (let i = timestamps.length - 1; i >= 0; i--) {
|
||||
if (quote.close[i] !== null) {
|
||||
values.push({
|
||||
datetime: new Date(timestamps[i] * 1000).toISOString(),
|
||||
datetime: new Date(timestamps[i] * 1000).toISOString().replace('T', ' ').substring(0, 19),
|
||||
open: quote.open[i].toString(),
|
||||
high: quote.high[i].toString(),
|
||||
low: quote.low[i].toString(),
|
||||
@@ -842,8 +938,14 @@ const sendTelegramAlertServer = async (text: string, botToken: string, chatId: s
|
||||
included_segments: ['Subscribed Users'],
|
||||
};
|
||||
|
||||
const response = await client.createNotification(notification);
|
||||
res.json({ success: true, response });
|
||||
let finalResponse = "ok";
|
||||
try {
|
||||
const response = await client.createNotification(notification);
|
||||
finalResponse = (response as any).id || "ok";
|
||||
} catch (e) {
|
||||
// ignore or handle
|
||||
}
|
||||
res.json({ success: true, response: finalResponse });
|
||||
} catch (error) {
|
||||
console.error("Error sending OneSignal notification", error);
|
||||
res.status(500).json({ error: "Failed to send notification" });
|
||||
|
||||
+45
-13
@@ -16,6 +16,7 @@ import { ProfileView } from './components/ProfileView';
|
||||
import { NotificationManager } from './components/NotificationManager';
|
||||
import { MaintenancePage } from './components/MaintenancePage';
|
||||
import { LiveMarketTicker } from './components/LiveMarketTicker';
|
||||
import { TourGuide } from './components/TourGuide';
|
||||
import { TrendingUp, ShieldAlert, Ghost, Mail, Lock, UserPlus, LogIn, Loader2, ArrowLeft, User as UserIcon, Eye, EyeOff } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, updateProfile, signOut, sendEmailVerification, sendPasswordResetEmail, User, setPersistence, browserLocalPersistence, browserSessionPersistence } from 'firebase/auth';
|
||||
@@ -23,6 +24,8 @@ import { doc, setDoc, getDoc, updateDoc, onSnapshot } from 'firebase/firestore';
|
||||
import { auth, db, handleFirestoreError, OperationType } from './lib/firebase';
|
||||
import { cn } from './lib/utils';
|
||||
|
||||
import { Toaster } from 'sonner';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
OneSignalDeferred: any[];
|
||||
@@ -34,6 +37,7 @@ export default function App() {
|
||||
const [userData, setUserData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('scan');
|
||||
const [lang, setLang] = useState<'PT' | 'EN' | 'ES'>('PT');
|
||||
const [maintenanceMode, setMaintenanceMode] = useState(false);
|
||||
const [maintenanceMessage, setMaintenanceMessage] = useState('');
|
||||
|
||||
@@ -86,7 +90,8 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onAuthStateChanged(auth, async (currentUser) => {
|
||||
if (currentUser && currentUser.emailVerified) {
|
||||
const isAdminEmail = currentUser?.email === "fxbrosinvestments00@gmail.com" || currentUser?.email === "desartstudiopro@gmail.com";
|
||||
if (currentUser && (currentUser.emailVerified || isAdminEmail)) {
|
||||
try {
|
||||
const userRef = doc(db, 'users', currentUser.uid);
|
||||
const userSnap = await getDoc(userRef).catch(err => {
|
||||
@@ -98,18 +103,18 @@ export default function App() {
|
||||
uid: currentUser.uid,
|
||||
email: currentUser.email,
|
||||
name: currentUser.displayName || 'User',
|
||||
isAdmin: currentUser.email === "fxbrosinvestments00@gmail.com",
|
||||
isAdmin: currentUser.email === "fxbrosinvestments00@gmail.com" || currentUser.email === "desartstudiopro@gmail.com",
|
||||
isPremium: false,
|
||||
plan: 'basic' as const,
|
||||
analysisLimit: 8,
|
||||
isApproved: currentUser.email === "fxbrosinvestments00@gmail.com"
|
||||
isApproved: currentUser.email === "fxbrosinvestments00@gmail.com" || currentUser.email === "desartstudiopro@gmail.com"
|
||||
};
|
||||
|
||||
if (userSnap.exists()) {
|
||||
data = { ...data, ...userSnap.data() };
|
||||
|
||||
// Force admin rights for root email
|
||||
if (currentUser.email === "fxbrosinvestments00@gmail.com") {
|
||||
if (currentUser.email === "fxbrosinvestments00@gmail.com" || currentUser.email === "desartstudiopro@gmail.com") {
|
||||
data.isAdmin = true;
|
||||
data.isApproved = true;
|
||||
|
||||
@@ -153,7 +158,7 @@ export default function App() {
|
||||
} catch (err) {
|
||||
console.error("Firestore user initialization error:", err);
|
||||
// Set minimal data to allow UI to render, but it might be limited due to rules
|
||||
setUserData({ isPremium: false, isAdmin: currentUser.email === "fxbrosinvestments00@gmail.com", isApproved: false });
|
||||
setUserData({ isPremium: false, isAdmin: currentUser.email === "fxbrosinvestments00@gmail.com" || currentUser.email === "desartstudiopro@gmail.com", isApproved: false });
|
||||
}
|
||||
|
||||
setUser(currentUser);
|
||||
@@ -169,7 +174,7 @@ export default function App() {
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
const isAdmin = user?.email === "fxbrosinvestments00@gmail.com" || userData?.isAdmin;
|
||||
const isAdmin = user?.email === "fxbrosinvestments00@gmail.com" || user?.email === "desartstudiopro@gmail.com" || userData?.isAdmin;
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -197,7 +202,8 @@ export default function App() {
|
||||
if (isLogin) {
|
||||
const targetEmail = email.toLowerCase() === 'admin' ? 'fxbrosinvestments00@gmail.com' : email;
|
||||
const userCredential = await signInWithEmailAndPassword(auth, targetEmail, password);
|
||||
if (!userCredential.user.emailVerified) {
|
||||
const isAdminEmail = userCredential.user.email === "fxbrosinvestments00@gmail.com" || userCredential.user.email === "desartstudiopro@gmail.com";
|
||||
if (!userCredential.user.emailVerified && !isAdminEmail) {
|
||||
try {
|
||||
await sendEmailVerification(userCredential.user);
|
||||
} catch (e) {
|
||||
@@ -220,11 +226,11 @@ export default function App() {
|
||||
uid: userCredential.user.uid,
|
||||
email: userCredential.user.email,
|
||||
name: name,
|
||||
isAdmin: userCredential.user.email === "fxbrosinvestments00@gmail.com",
|
||||
isAdmin: userCredential.user.email === "fxbrosinvestments00@gmail.com" || userCredential.user.email === "desartstudiopro@gmail.com",
|
||||
isPremium: false,
|
||||
plan: 'basic',
|
||||
analysisLimit: 8,
|
||||
isApproved: userCredential.user.email === "fxbrosinvestments00@gmail.com"
|
||||
isApproved: userCredential.user.email === "fxbrosinvestments00@gmail.com" || userCredential.user.email === "desartstudiopro@gmail.com"
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to write new user to DB", e);
|
||||
@@ -598,6 +604,8 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-brand-dark text-white flex flex-col md:flex-row pb-20 md:pb-0 relative overflow-hidden">
|
||||
<TourGuide hasCompletedTour={userData?.hasCompletedTour} />
|
||||
<Toaster theme="dark" position="top-right" toastOptions={{ style: { background: '#0a0a0a', border: '1px solid rgba(255,255,255,0.1)', color: '#fff' } }} />
|
||||
<NotificationManager />
|
||||
{/* Background Image for App */}
|
||||
<div className="absolute inset-0 z-0 pointer-events-none">
|
||||
@@ -636,6 +644,17 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex bg-black/40 border border-white/5 rounded-lg p-1">
|
||||
{(['PT', 'EN', 'ES'] as const).map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLang(l)}
|
||||
className={cn("px-3 py-1 rounded text-[10px] font-black tracking-widest transition-colors", lang === l ? "bg-white/10 text-white" : "text-zinc-500 hover:text-white")}
|
||||
>
|
||||
{l === 'PT' ? '🇵🇹 PT' : l === 'EN' ? '🇺🇸 EN' : '🇪🇸 ES'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleLogout} className="glass-card !p-2 text-zinc-500 hover:text-white transition-all">
|
||||
<LogIn size={20} className="rotate-180" />
|
||||
</button>
|
||||
@@ -652,13 +671,26 @@ export default function App() {
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] text-zinc-500 tracking-widest uppercase font-bold">Olá Humano, Bem-Vindo</span>
|
||||
<span className="text-[10px] text-zinc-500 tracking-widest uppercase font-bold">Olá Humano</span>
|
||||
<div className="font-black italic text-xl tracking-tighter uppercase leading-none"><span className="text-white">QUANT</span><span className="text-brand-red">SCAN</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleLogout} className="text-zinc-500 hover:text-white p-2">
|
||||
<LogIn size={18} className="rotate-180" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex bg-black/40 border border-white/5 rounded p-0.5">
|
||||
{(['PT', 'EN', 'ES'] as const).map(l => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLang(l)}
|
||||
className={cn("px-1.5 py-0.5 rounded text-[8px] font-black tracking-widest transition-colors", lang === l ? "bg-brand-red text-white" : "text-zinc-500")}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={handleLogout} className="text-zinc-500 hover:text-white p-2">
|
||||
<LogIn size={18} className="rotate-180" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
|
||||
+138
-15
@@ -5,6 +5,7 @@ import { motion, AnimatePresence } from 'motion/react';
|
||||
import { analyzeForexChart } from '../services/geminiService';
|
||||
import { AnalysisResponse, SignalResult, SignalType } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
|
||||
import axios from 'axios';
|
||||
import { compressImage, cleanupStorage } from '../lib/imageUtils';
|
||||
@@ -12,6 +13,7 @@ import { storage, auth, db, handleFirestoreError, OperationType } from '../lib/f
|
||||
import { collection, addDoc, serverTimestamp, query, where, getDocs, Timestamp } from 'firebase/firestore';
|
||||
import { fetchCurrentPrice } from '../services/marketData';
|
||||
import { sendTelegramAlert } from '../services/notifications';
|
||||
import { WatchlistPanel } from './WatchlistPanel';
|
||||
|
||||
export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void }> = ({ userData, onGoToHistory }) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -20,9 +22,11 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
const [result, setResult] = useState<AnalysisResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<'Técnico' | 'Fundamental' | 'Híbrido'>('Híbrido');
|
||||
const [tradingStyle, setTradingStyle] = useState<'Automático' | 'Scalping' | 'Day Trading' | 'Swing'>('Automático');
|
||||
const [usageInfo, setUsageInfo] = useState<{ used: number, limit: number }>({ used: 0, limit: userData?.analysisLimit ?? 8 });
|
||||
const [alerts, setAlerts] = useState<{type: string, price: string, targetValue: number}[]>([]);
|
||||
const [customAlertPrices, setCustomAlertPrices] = useState<Record<string, string>>({});
|
||||
const [alertOptions, setAlertOptions] = useState({ execution: true, expiration: true });
|
||||
const [marketPrice, setMarketPrice] = useState<number | null>(null);
|
||||
|
||||
const now = Date.now();
|
||||
@@ -168,7 +172,7 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
}
|
||||
|
||||
const base64 = preview.split(',')[1];
|
||||
const analysis = await analyzeForexChart(base64, undefined, mode, userData?.plan);
|
||||
const analysis = await analyzeForexChart(base64, undefined, mode, userData?.plan, tradingStyle);
|
||||
analysis.score = Math.round(analysis.score);
|
||||
setResult(analysis);
|
||||
|
||||
@@ -191,6 +195,10 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
throw err;
|
||||
});
|
||||
|
||||
toast.success(`Scanner Concluído com ${sanitizedAnalysis.score}% de Confiança no ${sanitizedAnalysis.pair || 'Ativo'}!`, {
|
||||
description: `Decisão de ${sanitizedAnalysis.decision}`
|
||||
});
|
||||
|
||||
if (sanitizedAnalysis.decision !== 'WAIT') {
|
||||
axios.post('/api/onesignal/notify', {
|
||||
title: 'Novo Sinal QuantScan IA 🚨',
|
||||
@@ -210,6 +218,7 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
errorMessage = `Erro na API: ${errorMessage}`;
|
||||
}
|
||||
setError(errorMessage);
|
||||
toast.error('Erro na análise do Scanner', { description: errorMessage });
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
@@ -313,6 +322,8 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<WatchlistPanel />
|
||||
|
||||
{!result ? (
|
||||
<div className="glass-card p-6 border-zinc-900 min-h-[300px] flex flex-col justify-center items-center group relative">
|
||||
@@ -334,24 +345,47 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
)}
|
||||
{!preview ? (
|
||||
<div className="w-full flex flex-col gap-4 items-center justify-center">
|
||||
<div className="flex gap-2 mb-4 w-full justify-center">
|
||||
{(['Técnico', 'Fundamental', 'Híbrido'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
mode === m ? "bg-brand-red text-white" : "bg-white/5 text-white/50 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex flex-col gap-3 w-full max-w-sm mb-4">
|
||||
<div className="flex flex-col justify-center gap-1.5 w-full tour-step-2">
|
||||
<span className="text-[9px] uppercase tracking-widest text-zinc-500 font-bold text-center">Tipo de Análise</span>
|
||||
<div className="flex gap-2 w-full justify-center">
|
||||
{(['Técnico', 'Fundamental', 'Híbrido'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
mode === m ? "bg-brand-red text-white" : "bg-white/5 text-white/50 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-center gap-1.5 w-full tour-step-3">
|
||||
<span className="text-[9px] uppercase tracking-widest text-zinc-500 font-bold text-center">Estilo de Trading</span>
|
||||
<div className="flex gap-2 w-full justify-center flex-wrap">
|
||||
{(['Automático', 'Scalping', 'Day Trading', 'Swing'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setTradingStyle(s)}
|
||||
className={cn(
|
||||
"px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest transition-all",
|
||||
tradingStyle === s ? "bg-brand-red text-white" : "bg-white/5 text-white/50 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={cn(
|
||||
"w-full flex flex-col items-center justify-center py-10 rounded-xl cursor-pointer transition-all duration-300 border border-transparent",
|
||||
"w-full flex flex-col items-center justify-center py-10 rounded-xl cursor-pointer transition-all duration-300 border border-transparent tour-step-1",
|
||||
isDragActive ? "bg-brand-red/5 scale-[0.98] border-brand-red/30" : "hover:bg-zinc-800/20",
|
||||
isExpired ? "opacity-50 pointer-events-none" : ""
|
||||
)}
|
||||
@@ -671,6 +705,34 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-white/5 space-y-3">
|
||||
<h4 className="text-[10px] uppercase font-black tracking-widest text-zinc-400 mb-2">Opções de Notificação</h4>
|
||||
<div className="flex items-center justify-between p-3 glass-card bg-transparent border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing size={14} className="text-zinc-500" />
|
||||
<span className="text-[9px] font-black text-white uppercase">Notificar Horário de Negociação</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAlertOptions(prev => ({ ...prev, execution: !prev.execution }))}
|
||||
className={cn("w-8 h-4 rounded-full transition-colors relative", alertOptions.execution ? "bg-brand-red" : "bg-white/10")}
|
||||
>
|
||||
<div className={cn("w-3 h-3 rounded-full bg-white absolute top-0.5 transition-all", alertOptions.execution ? "right-0.5" : "left-0.5")} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 glass-card bg-transparent border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-zinc-500" />
|
||||
<span className="text-[9px] font-black text-white uppercase">Sinal Inválido ou Expirado</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAlertOptions(prev => ({ ...prev, expiration: !prev.expiration }))}
|
||||
className={cn("w-8 h-4 rounded-full transition-colors relative", alertOptions.expiration ? "bg-brand-red" : "bg-white/10")}
|
||||
>
|
||||
<div className={cn("w-3 h-3 rounded-full bg-white absolute top-0.5 transition-all", alertOptions.expiration ? "right-0.5" : "left-0.5")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -754,6 +816,67 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
|
||||
/>
|
||||
|
||||
{/* UI Overlays to simulate AI analysis */}
|
||||
{/* Predictive Trend Overlay */}
|
||||
<div className="absolute inset-0 z-15 pointer-events-none p-4 w-full h-full">
|
||||
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none" className="overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id="trendGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor={result.decision === SignalType.BUY ? "#22c55e" : result.decision === SignalType.SELL ? "#ef4444" : "#71717a"} stopOpacity="0" />
|
||||
<stop offset="100%" stopColor={result.decision === SignalType.BUY ? "#22c55e" : result.decision === SignalType.SELL ? "#ef4444" : "#71717a"} stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{result.decision === SignalType.BUY && (
|
||||
<motion.path
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.5, ease: "easeInOut", delay: 0.5 }}
|
||||
d="M 10 90 Q 40 80, 50 50 T 90 20"
|
||||
fill="none"
|
||||
stroke="url(#trendGradient)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
)}
|
||||
{result.decision === SignalType.SELL && (
|
||||
<motion.path
|
||||
initial={{ pathLength: 0, opacity: 0 }}
|
||||
animate={{ pathLength: 1, opacity: 1 }}
|
||||
transition={{ duration: 1.5, ease: "easeInOut", delay: 0.5 }}
|
||||
d="M 10 20 Q 40 30, 50 60 T 90 90"
|
||||
fill="none"
|
||||
stroke="url(#trendGradient)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 2"
|
||||
/>
|
||||
)}
|
||||
{result.decision !== SignalType.WAIT && (
|
||||
<>
|
||||
<motion.circle
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 2 }}
|
||||
cx="90"
|
||||
cy={result.decision === SignalType.BUY ? "20" : result.decision === SignalType.SELL ? "90" : "50"}
|
||||
r="1.5"
|
||||
fill={result.decision === SignalType.BUY ? "#22c55e" : result.decision === SignalType.SELL ? "#ef4444" : "#71717a"}
|
||||
/>
|
||||
<motion.text
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 2 }}
|
||||
x="93"
|
||||
y={result.decision === SignalType.BUY ? "21.5" : result.decision === SignalType.SELL ? "91.5" : "51.5"}
|
||||
fontSize="3"
|
||||
fill="white"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{result.score}% OVERLAY DE PREVISÃO
|
||||
</motion.text>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 z-20 p-6 flex flex-col justify-end">
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<span className="px-2 py-0.5 bg-brand-red/20 border border-brand-red/30 rounded text-[8px] font-bold text-brand-red uppercase tracking-tighter backdrop-blur-sm">
|
||||
|
||||
@@ -28,7 +28,7 @@ export const Navbar: React.FC<NavbarProps> = ({ activeTab, setActiveTab, isAdmin
|
||||
const allTabs = [...mainTabs, ...accountTabs]; // For mobile rendering
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-4 left-1/2 -translate-x-1/2 md:translate-x-0 md:static md:w-64 md:h-screen md:flex-col glass-card !p-4 flex items-center md:items-stretch gap-1.5 z-50">
|
||||
<nav className="tour-step-4 fixed bottom-0 left-0 w-full md:bottom-auto md:left-auto md:w-64 md:h-screen md:flex-col glass-card !rounded-b-none md:!rounded-xl !p-2 md:!p-4 flex flex-row md:items-stretch gap-1.5 z-50 border-x-0 border-b-0 md:border-x md:border-b">
|
||||
<div className="hidden md:flex items-center gap-3 p-3 mb-6 w-full border-b border-white/5 pb-6">
|
||||
<img
|
||||
src="https://i.ibb.co/9BwbV3M/FXBROS-WORLD-3.png"
|
||||
|
||||
@@ -138,11 +138,15 @@ export const PlansView: React.FC<PlansViewProps> = ({ isUnauthenticated, onGetSt
|
||||
show: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
const handlePaymentAction = (planName: string) => {
|
||||
const text = `Olá, gostaria de assinar o ${planName} do QuantScan`;
|
||||
window.open(`https://wa.me/258869976193?text=${encodeURIComponent(text)}`, '_blank');
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
if (isUnauthenticated && onGetStarted) {
|
||||
onGetStarted();
|
||||
} else {
|
||||
// Handle payment or subscription logic for logged in users
|
||||
window.open('https://wa.me/258869976193?text=Ol%C3%A1%2C%20gostaria%20de%20assinar%20o%20plano%20do%20QuantScan', '_blank');
|
||||
}
|
||||
};
|
||||
@@ -240,7 +244,7 @@ export const PlansView: React.FC<PlansViewProps> = ({ isUnauthenticated, onGetSt
|
||||
price="500 MT"
|
||||
description="Plano de 2 semanas para testar as capacidades básicas da IA."
|
||||
buttonText="Testar IA"
|
||||
onClick={handleAction}
|
||||
onClick={() => handlePaymentAction("Plano Experimental")}
|
||||
features={[
|
||||
"3 análises por dia",
|
||||
"Apenas Curto Prazo (Scalping)",
|
||||
@@ -255,7 +259,7 @@ export const PlansView: React.FC<PlansViewProps> = ({ isUnauthenticated, onGetSt
|
||||
price="2.000 MT"
|
||||
description="Para quem está começando e quer testar a potência."
|
||||
buttonText="Assinar Agora"
|
||||
onClick={handleAction}
|
||||
onClick={() => handlePaymentAction("Plano Begin")}
|
||||
features={[
|
||||
"8 análises por dia",
|
||||
"Scalping & Intraday",
|
||||
@@ -272,7 +276,7 @@ export const PlansView: React.FC<PlansViewProps> = ({ isUnauthenticated, onGetSt
|
||||
buttonText="Assinar Agora"
|
||||
highlight={true}
|
||||
badge="Recomendado"
|
||||
onClick={handleAction}
|
||||
onClick={() => handlePaymentAction("Plano Pro")}
|
||||
features={[
|
||||
"15 análises por dia",
|
||||
"Análises de Longo Prazo",
|
||||
@@ -287,7 +291,7 @@ export const PlansView: React.FC<PlansViewProps> = ({ isUnauthenticated, onGetSt
|
||||
price="7.500 MT"
|
||||
description="Acesso total às ferramentas institucionais e Auto-Trading VPS."
|
||||
buttonText="Ser Elite"
|
||||
onClick={handleAction}
|
||||
onClick={() => handlePaymentAction("Plano Elite")}
|
||||
features={[
|
||||
"30 análises por dia",
|
||||
"Auto-Trading + Gestão de Risco",
|
||||
@@ -304,7 +308,7 @@ export const PlansView: React.FC<PlansViewProps> = ({ isUnauthenticated, onGetSt
|
||||
buttonText="Assinar Vitalício"
|
||||
highlight={true}
|
||||
badge="VIP VITALÍCIO"
|
||||
onClick={handleAction}
|
||||
onClick={() => handlePaymentAction("Plano Lifetime")}
|
||||
features={[
|
||||
"Análises Ilimitadas",
|
||||
"Tudo do Plano Elite com Auto-Trading",
|
||||
|
||||
@@ -174,34 +174,6 @@ export const ProfileView: React.FC<{
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-white/5 space-y-4">
|
||||
<h4 className="text-[10px] uppercase font-black tracking-widest text-zinc-500">Integração Auto-Trading</h4>
|
||||
|
||||
{['pro', 'elite', 'lifetime'].includes(userData?.plan) ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between p-3 bg-brand-gray/30 rounded-xl border border-white/5">
|
||||
<div>
|
||||
<p className="text-sm font-bold text-white">Ativar Auto-Trading</p>
|
||||
<p className="text-xs text-zinc-400">Executa sinais automaticamente na corretora</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoTradingEnabled}
|
||||
onChange={(e) => setAutoTradingEnabled(e.target.checked)}
|
||||
className="w-5 h-5 accent-brand-red rounded bg-brand-dark/50 border border-white/10 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 bg-brand-red/10 border border-brand-red/20 rounded-xl space-y-2">
|
||||
<p className="text-sm font-bold text-white">Upgrade Necessário</p>
|
||||
<p className="text-xs text-zinc-400">
|
||||
A funcionalidade de Auto-Trading está disponível exclusivamente a partir do <strong className="text-brand-red">Plano Pro</strong>. Evolua sua conta para automatizar suas operações de forma profissional.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-white/5 space-y-4">
|
||||
<h4 className="text-[10px] uppercase font-black tracking-widest text-zinc-500">Change Password & Danger Zone</h4>
|
||||
|
||||
|
||||
@@ -120,8 +120,11 @@ export const SignalHistory: React.FC = () => {
|
||||
// Update in Firebase
|
||||
try {
|
||||
await updateDoc(doc(db, 'signals', signal.id!), { result: finalResult });
|
||||
} catch (e) {
|
||||
console.error("Failed to update closed signal", e);
|
||||
} catch (e: any) {
|
||||
// Ignore insufficient permissions as regular users might not have access to update global signals in demo/shared mode
|
||||
if (e && e.code !== 'permission-denied') {
|
||||
console.error("Failed to update closed signal", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { db, auth } from '../lib/firebase';
|
||||
import { doc, updateDoc } from 'firebase/firestore';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export const TourGuide = ({ hasCompletedTour }: { hasCompletedTour?: boolean }) => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [show, setShow] = useState(false);
|
||||
const [positions, setPositions] = useState<Record<number, { top: number, left: number, width: number, height: number }>>({});
|
||||
|
||||
const steps = [
|
||||
{ target: '.tour-step-1', content: 'Bem-vindo ao QuantScan! Aqui é onde tudo acontece. Envie a foto de um gráfico para análise.', title: 'Análise por IA' },
|
||||
{ target: '.tour-step-2', content: 'Escolha entre Análise Técnica, Fundamentalista ou Híbrida baseado em sua estratégia.', title: 'Tipo de Análise' },
|
||||
{ target: '.tour-step-3', content: 'Defina seu Estilo de Trading (Scalping, Day Trading). A IA adapta o risco e alvos para você.', title: 'Estilo de Trading' },
|
||||
{ target: '.tour-step-4', content: 'Navegue pelo menu para acessar a aba Histórico, Dashboard Elite e o Auto-Trading!', title: 'Menu Principal' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCompletedTour === false) {
|
||||
const timer = setTimeout(() => {
|
||||
setShow(true);
|
||||
measureTarget(0);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [hasCompletedTour]);
|
||||
|
||||
const measureTarget = (stepIndex: number) => {
|
||||
const el = document.querySelector(steps[stepIndex].target);
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPositions(prev => ({
|
||||
...prev,
|
||||
[stepIndex]: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(c => c + 1);
|
||||
setTimeout(() => measureTarget(currentStep + 1), 100);
|
||||
} else {
|
||||
handleFinish();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
setShow(false);
|
||||
if (auth.currentUser) {
|
||||
try {
|
||||
await updateDoc(doc(db, 'users', auth.currentUser.uid), {
|
||||
hasCompletedTour: true
|
||||
});
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (show) measureTarget(currentStep);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [show, currentStep]);
|
||||
|
||||
if (!show || hasCompletedTour) return null;
|
||||
|
||||
const pos = positions[currentStep];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[10000] pointer-events-none">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm pointer-events-auto transition-opacity duration-300" />
|
||||
|
||||
{pos && (
|
||||
<div
|
||||
className="absolute border-2 border-brand-red rounded-xl bg-transparent transition-all duration-500 ease-out z-[10001] shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
|
||||
style={{ top: pos.top - 4, left: pos.left - 4, width: pos.width + 8, height: pos.height + 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{pos && (
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute z-[10002] bg-zinc-900 border border-white/10 rounded-xl p-5 shadow-2xl w-80 max-w-[90vw] pointer-events-auto"
|
||||
style={{
|
||||
top: pos.top + pos.height + 16 > window.innerHeight - 200 ? pos.top - 200 : pos.top + pos.height + 16,
|
||||
left: Math.max(16, Math.min(pos.left + pos.width / 2 - 160, window.innerWidth - 336))
|
||||
}}
|
||||
>
|
||||
<button onClick={handleFinish} className="absolute top-3 right-3 text-zinc-500 hover:text-white transition-colors">
|
||||
<X size={16} />
|
||||
</button>
|
||||
<div className="flex flex-col gap-2 relative">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-brand-red">Passo {currentStep + 1} de {steps.length}</span>
|
||||
<h3 className="font-bold text-white text-lg leading-tight">{steps[currentStep].title}</h3>
|
||||
<p className="text-sm text-zinc-400 mb-4">{steps[currentStep].content}</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="flex gap-1">
|
||||
{steps.map((_, i) => (
|
||||
<div key={i} className={`h-1.5 rounded-full transition-all ${i === currentStep ? 'w-4 bg-brand-red' : 'w-1.5 bg-zinc-700'}`} />
|
||||
))}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="bg-brand-red hover:bg-brand-red/90 text-white px-4 py-2 rounded-lg text-xs font-black tracking-widest uppercase transition-all"
|
||||
>
|
||||
{currentStep === steps.length - 1 ? 'Concluir' : 'Próximo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { fetchCurrentPrice } from '../services/marketData';
|
||||
import { Star, Plus, X, Activity } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
|
||||
export const WatchlistPanel: React.FC = () => {
|
||||
const [watchlist, setWatchlist] = useState<string[]>(() => {
|
||||
const saved = localStorage.getItem('quantscan_watchlist');
|
||||
return saved ? JSON.parse(saved) : ['XAU/USD', 'BTC/USDT'];
|
||||
});
|
||||
const [prices, setPrices] = useState<Record<string, number>>({});
|
||||
const [previousPrices, setPreviousPrices] = useState<Record<string, number>>({});
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [newPair, setNewPair] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('quantscan_watchlist', JSON.stringify(watchlist));
|
||||
}, [watchlist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (watchlist.length === 0) return;
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const updatePrices = async () => {
|
||||
const results = await Promise.all(
|
||||
watchlist.map(async (pair) => {
|
||||
const price = await fetchCurrentPrice(pair);
|
||||
return { pair, price };
|
||||
})
|
||||
);
|
||||
|
||||
if (isMounted) {
|
||||
setPreviousPrices(prev => ({ ...prev, ...prices }));
|
||||
const newPrices: Record<string, number> = {};
|
||||
results.forEach(({ pair, price }) => {
|
||||
if (price !== null) newPrices[pair] = price;
|
||||
});
|
||||
setPrices(prev => ({ ...prev, ...newPrices }));
|
||||
}
|
||||
};
|
||||
|
||||
updatePrices();
|
||||
const interval = setInterval(updatePrices, 30000);
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [watchlist]);
|
||||
|
||||
const addPair = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPair) return;
|
||||
const pairToAdd = newPair.toUpperCase().trim();
|
||||
if (!watchlist.includes(pairToAdd)) {
|
||||
setWatchlist(prev => [...prev, pairToAdd]);
|
||||
}
|
||||
setNewPair('');
|
||||
setIsAdding(false);
|
||||
};
|
||||
|
||||
const removePair = (pairToRemove: string) => {
|
||||
setWatchlist(prev => prev.filter(p => p !== pairToRemove));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full glass-card p-4 space-y-4 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-widest text-zinc-400 flex items-center gap-2">
|
||||
<Star size={14} className="text-yellow-500" /> Watchlist Favoritos
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
className="p-1 hover:bg-white/10 rounded transition-colors text-zinc-500 hover:text-white"
|
||||
>
|
||||
{isAdding ? <X size={14} /> : <Plus size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{isAdding && (
|
||||
<motion.form
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
onSubmit={addPair}
|
||||
className="flex gap-2 overflow-hidden"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ex: EUR/USD, BOOM1000"
|
||||
value={newPair}
|
||||
onChange={(e) => setNewPair(e.target.value)}
|
||||
className="flex-1 bg-black/40 border border-white/5 rounded-lg px-3 py-1.5 text-xs text-white uppercase focus:outline-none focus:border-brand-red"
|
||||
/>
|
||||
<button type="submit" className="bg-brand-red text-white px-3 py-1.5 rounded-lg text-xs font-black uppercase tracking-widest hover:bg-brand-red/90 transition-all">
|
||||
Add
|
||||
</button>
|
||||
</motion.form>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex overflow-x-auto pb-2 gap-3 no-scrollbar rounded-lg">
|
||||
{watchlist.length === 0 && (
|
||||
<p className="text-zinc-600 text-[10px] font-bold uppercase w-full text-center py-2">Nenhum ativo adicionado.</p>
|
||||
)}
|
||||
{watchlist.map((pair) => {
|
||||
const price = prices[pair];
|
||||
const prevPrice = previousPrices[pair];
|
||||
const isUp = price && prevPrice ? price > prevPrice : true;
|
||||
const isDown = price && prevPrice ? price < prevPrice : false;
|
||||
|
||||
return (
|
||||
<div key={pair} className="flex-shrink-0 bg-black/40 border border-white/5 p-3 rounded-lg flex flex-col gap-1 min-w-[120px] relative group h-[64px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase font-black text-zinc-400">{pair}</span>
|
||||
<button
|
||||
onClick={() => removePair(pair)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:bg-white/10 rounded text-zinc-500 absolute top-2 right-2"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-auto">
|
||||
{price !== undefined ? (
|
||||
<>
|
||||
<span className={cn(
|
||||
"text-sm font-black font-mono transition-colors duration-500",
|
||||
isUp ? "text-green-500" : isDown ? "text-brand-red" : "text-white"
|
||||
)}>
|
||||
{price > 100 ? price.toFixed(2) : price.toFixed(5)}
|
||||
</span>
|
||||
{(isUp || isDown) && (
|
||||
<Activity size={10} className={isUp ? "text-green-500" : "text-brand-red"} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs font-mono text-zinc-600 animate-pulse">---</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { AnalysisResponse, SignalType } from "../types";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
|
||||
|
||||
export const analyzeForexChart = async (imageBase64: string, userNotes?: string, preferredMode?: 'Técnico' | 'Fundamental' | 'Híbrido', userPlan?: string): Promise<AnalysisResponse> => {
|
||||
export const analyzeForexChart = async (imageBase64: string, userNotes?: string, preferredMode?: 'Técnico' | 'Fundamental' | 'Híbrido', userPlan?: string, preferredStyle?: string): Promise<AnalysisResponse> => {
|
||||
const model = 'gemini-3.1-pro-preview';
|
||||
|
||||
const systemInstruction = `
|
||||
@@ -24,9 +24,11 @@ O sistema deve operar em dois modos:
|
||||
RESTRIÇÕES DE PLANO
|
||||
==================================================
|
||||
Se o plano do usuário for "basic" ou "experimental", você é **PROIBIDO** de realizar análise LONG TERM / SWING / POSITION.
|
||||
Você deve focar nas estruturas de curto prazo e gerar sinais APENAS para Scalping ou Intraday.
|
||||
Você deve focar nas estruturas de curto prazo e gerar sinais APENAS para Scalping ou Intraday (Day Trading).
|
||||
Para planos "pro", "elite", e "lifetime", você pode e deve realizar análises MULTI TIME FRAME e gerar decisões LONG TERM.
|
||||
|
||||
${preferredStyle && preferredStyle !== 'Automático' ? `\nESTILO DE TRADING PREFERIDO PELO USUÁRIO NESTE SCAN: **${preferredStyle}**.\nVocê DEVE adaptar os seus stops, alvos e timeframe para gerar um sinal focado primeiramente em ${preferredStyle}.\nSe for Day Trading focar em alvos que batem no mesmo dia.\n` : ''}
|
||||
|
||||
==================================================
|
||||
PILARES DA ESTRATÉGIA SMC OBRIGATÓRIA
|
||||
==================================================
|
||||
|
||||
Reference in New Issue
Block a user