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:
desartstudio95
2026-06-09 04:35:32 -07:00
parent 8788d7c1e6
commit f7a97e3532
13 changed files with 1127 additions and 446 deletions
+2 -1
View File
@@ -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 ---
+525 -358
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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"
+122 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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">
+1 -1
View File
@@ -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"
+10 -6
View File
@@ -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",
-28
View File
@@ -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>
+5 -2
View File
@@ -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);
}
}
}
}
+125
View File
@@ -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>
);
};
+148
View File
@@ -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>
);
};
+4 -2
View File
@@ -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
==================================================