diff --git a/firestore.rules b/firestore.rules index 9a1aa63..61207a5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -86,8 +86,8 @@ service cloud.firestore { function isValidSignal(data) { return data.keys().hasAll(['userId', 'pair', 'timeframe', 'decision', 'score', 'timestamp', 'result']) && data.userId == request.auth.uid - && data.pair is string && data.pair.size() <= 20 - && data.timeframe is string && data.timeframe.size() <= 10 + && data.pair is string && data.pair.size() <= 100 + && data.timeframe is string && data.timeframe.size() <= 50 && data.decision is string && data.score is int && data.score >= 0 && data.score <= 100 && data.timestamp is int diff --git a/src/components/AnalysisView.tsx b/src/components/AnalysisView.tsx index f050886..b819f6e 100644 --- a/src/components/AnalysisView.tsx +++ b/src/components/AnalysisView.tsx @@ -1,11 +1,12 @@ import React, { useState, useCallback, useEffect } from 'react'; import { useDropzone } from 'react-dropzone'; -import { Upload, X, ShieldCheck, Target, TrendingUp, AlertTriangle, Loader2, Zap, BrainCircuit, Camera, ScanLine } from 'lucide-react'; +import { Upload, X, ShieldCheck, Target, TrendingUp, AlertTriangle, Loader2, Zap, BrainCircuit, Camera, ScanLine, Bell, BellRing } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { analyzeForexChart } from '../services/geminiService'; import { AnalysisResponse, SignalResult, SignalType } from '../types'; import { cn } from '../lib/utils'; import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'; +import { compressImage, cleanupStorage } from '../lib/imageUtils'; import { storage, auth, db, handleFirestoreError, OperationType } from '../lib/firebase'; import { collection, addDoc, serverTimestamp, query, where, getDocs, Timestamp } from 'firebase/firestore'; @@ -17,6 +18,8 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void const [error, setError] = useState(null); const [mode, setMode] = useState<'Técnico' | 'Fundamental' | 'Híbrido'>('Híbrido'); 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 [mockPrice, setMockPrice] = useState(null); const now = Date.now(); const isLifetime = userData?.plan === 'lifetime'; @@ -25,6 +28,68 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void const daysRemaining = !isLifetime && endsAt ? (endsAt - now) / (1000 * 60 * 60 * 24) : null; const isEndingSoon = daysRemaining !== null && Math.ceil(daysRemaining) <= 3 && Math.ceil(daysRemaining) > 0; + useEffect(() => { + if (result && result.entry) { + const entryNum = parseFloat(result.entry.replace(/[^0-9.]/g, '')); + if (!isNaN(entryNum)) setMockPrice(entryNum); + if ('Notification' in window && Notification.permission !== 'granted') { + Notification.requestPermission(); + } + } else { + setMockPrice(null); + setAlerts([]); + } + }, [result]); + + useEffect(() => { + if (mockPrice === null || alerts.length === 0 || !result) return; + + const interval = setInterval(() => { + setMockPrice(prevPrice => { + if (prevPrice === null) return null; + // Simulando variação realista (0.01% - 0.05%) + const movePercent = (Math.random() - 0.5) * 0.001; + let nextPrice = prevPrice * (1 + movePercent); + + // Vamos forçar o preço a ir em direção a um dos alertas para que a feature possa ser testada + if (Math.random() > 0.4 && alerts.length > 0) { + const target = alerts[Math.floor(Math.random() * alerts.length)].targetValue; + if (target > nextPrice) { + nextPrice += target * 0.0002; + } else { + nextPrice -= target * 0.0002; + } + } + + let triggeredIndex = -1; + const triggeredAlert = alerts.find((alert, index) => { + const isTriggered = Math.abs(nextPrice - alert.targetValue) < (alert.targetValue * 0.0002) || + (prevPrice < alert.targetValue && nextPrice >= alert.targetValue) || + (prevPrice > alert.targetValue && nextPrice <= alert.targetValue); + if (isTriggered) triggeredIndex = index; + return isTriggered; + }); + + if (triggeredAlert && triggeredIndex !== -1) { + if ('Notification' in window && Notification.permission === 'granted') { + new Notification('🚨 Alerta QuantScan', { + body: `O preço do ativo ${result.pair} atingiu seu alvo de ${triggeredAlert.type} (${triggeredAlert.price}).`, + icon: 'https://i.ibb.co/9BwbV3M/FXBROS-WORLD-3.png' + }); + } else { + window.alert(`🚨 Alerta QuantScan: O preço do ativo ${result.pair} atingiu seu alvo de ${triggeredAlert.type} (${triggeredAlert.price}).`); + } + + setAlerts(prev => prev.filter((_, i) => i !== triggeredIndex)); + } + + return nextPrice; + }); + }, 2000); + + return () => clearInterval(interval); + }, [alerts, mockPrice, result]); + useEffect(() => { const fetchUsage = async () => { if (!auth.currentUser) return; @@ -80,6 +145,9 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void const fileRef = ref(storage, `scans/${auth.currentUser.uid}/${Date.now()}_${file.name}`); await uploadBytes(fileRef, file); downloadUrl = await getDownloadURL(fileRef); + + // Clean up old scans for this user to avoid storage limits (keep max 100) + cleanupStorage(`scans/${auth.currentUser.uid}`, 100); } const base64 = preview.split(',')[1]; @@ -103,10 +171,12 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void } } catch (err: any) { - console.error(err); + console.error("Gemini Error:", err); let errorMessage = err.message || 'Falha ao analisar imagem. Verifique se o gráfico está claro.'; if (errorMessage.includes('429') || errorMessage.includes('RESOURCE_EXHAUSTED') || errorMessage.includes('prepayment credits')) { errorMessage = 'Sua cota de uso da API do Google Gemini (IA) foi excedida ou os créditos acabaram. Por favor, acesse o painel do Google AI Studio (https://ai.studio) para verificar seu faturamento e recarregar os créditos.'; + } else { + errorMessage = `Erro na API: ${errorMessage}`; } setError(errorMessage); } finally { @@ -114,14 +184,18 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void } }; - const onDrop = useCallback((acceptedFiles: File[]) => { + const onDrop = useCallback(async (acceptedFiles: File[]) => { const selectedFile = acceptedFiles[0]; - setFile(selectedFile); + + // Comprimir a imagem antes de setar state para reduzir MS + const compressedFile = await compressImage(selectedFile, 1200, 0.7); + + setFile(compressedFile); const reader = new FileReader(); reader.onload = () => { setPreview(reader.result as string); }; - reader.readAsDataURL(selectedFile); + reader.readAsDataURL(compressedFile); setError(null); }, []); @@ -136,6 +210,22 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void setPreview(null); setResult(null); setError(null); + setMockPrice(null); + setAlerts([]); + }; + + const toggleAlert = (type: string, priceStr: string) => { + const numPrice = parseFloat(priceStr.replace(/[^0-9.]/g, '')); + if (isNaN(numPrice)) return; + + setAlerts(prev => { + const exists = prev.some(a => a.type === type); + if (exists) { + return prev.filter(a => a.type !== type); + } else { + return [...prev, { type, price: priceStr, targetValue: numPrice }]; + } + }); }; return ( @@ -389,27 +479,67 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void Decisão QuantScan + {mockPrice !== null && ( +
+ Preço de Mercado (Simulado) + {mockPrice.toFixed(5)} +
+ )} +
ENTRADA
- {result.entry} +
+ {result.entry} + +
STOP LOSS
- {result.stopLoss} +
+ {result.stopLoss} + +
TAKE PROFIT
- {result.takeProfit} +
+ {result.takeProfit} + +
diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx index 2205d9e..2e70b5c 100644 --- a/src/components/ImageUploader.tsx +++ b/src/components/ImageUploader.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { ref, uploadBytes, getDownloadURL } from 'firebase/storage'; import { storage, auth } from '../lib/firebase'; +import { compressImage, cleanupStorage } from '../lib/imageUtils'; interface ImageUploaderProps { onUpload: (urls: string[]) => void; @@ -18,11 +19,16 @@ export const ImageUploader: React.FC = ({ onUpload, classNam try { const uploadPromises = files.map(async (file) => { - const storageRef = ref(storage, `testimonials/${auth.currentUser!.uid}/${Date.now()}_${file.name}`); - await uploadBytes(storageRef, file); + const compressedFile = await compressImage(file, 1200, 0.7); + const storageRef = ref(storage, `testimonials/${auth.currentUser!.uid}/${Date.now()}_${compressedFile.name}`); + await uploadBytes(storageRef, compressedFile); return getDownloadURL(storageRef); }); const urls = await Promise.all(uploadPromises); + + // Limpar imagens antigas de depoimentos do usuário (máximo de 100) + cleanupStorage(`testimonials/${auth.currentUser!.uid}`, 100); + onUpload(urls); } catch (error) { console.error(error); diff --git a/src/lib/imageUtils.ts b/src/lib/imageUtils.ts new file mode 100644 index 0000000..1a84cbc --- /dev/null +++ b/src/lib/imageUtils.ts @@ -0,0 +1,65 @@ +import { storage } from './firebase'; +import { listAll, deleteObject, ref } from 'firebase/storage'; + +export const compressImage = (file: File, maxWidth = 1200, quality = 0.7): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const img = new Image(); + img.src = event.target?.result as string; + img.onload = () => { + const canvas = document.createElement('canvas'); + let width = img.width; + let height = img.height; + + if (width > maxWidth) { + height = Math.round((height * maxWidth) / width); + width = maxWidth; + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(file); // falha segura + return; + } + ctx.drawImage(img, 0, 0, width, height); + + canvas.toBlob((blob) => { + if (!blob) { + resolve(file); + return; + } + const compressedFile = new File([blob], file.name, { + type: 'image/jpeg', + lastModified: Date.now(), + }); + resolve(compressedFile); + }, 'image/jpeg', quality); + }; + img.onerror = (err) => resolve(file); // falha segura + }; + reader.onerror = (err) => resolve(file); // falha segura + }); +}; + +export const cleanupStorage = async (path: string, maxFiles: number = 100) => { + try { + const listRef = ref(storage, path); + const res = await listAll(listRef); + if (res.items.length > maxFiles) { + // Sort by name (which starts with Date.now()_filename, so alphabetical sort works as chronological sort) + const items = res.items.sort((a, b) => a.name.localeCompare(b.name)); + // Delete older items (from beginning of sorted array) + const itemsToDelete = items.slice(0, items.length - maxFiles); + + const deletePromises = itemsToDelete.map((itemRef) => deleteObject(itemRef)); + await Promise.all(deletePromises); + console.log(`Cleaned up ${itemsToDelete.length} files from ${path}`); + } + } catch (error) { + console.warn("Cleanup storage error:", error); + } +}; diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index 8f446f6..fe04a22 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -4,12 +4,24 @@ 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'): Promise => { - const model = "gemini-2.5-pro"; + const model = 'gemini-3.1-pro-preview'; const systemInstruction = ` Você é o QuantScan IA, um sistema avançado de análise de mercado financeiro com inteligência institucional. - Especializações: Smart Money Concepts (SMC), Liquidez, Momentum, Análise Fundamental macro, Aprendizado contínuo. + BASE PRINCIPAL: Smart Money Concepts (SMC). + Especializações: SMC, Liquidez, Momentum, Análise Fundamental macro, Aprendizado contínuo. + + CAMADA DE CONFIRMAÇÃO (APENAS PARA FILTRO, MEDIDOR DE CONTEXTO OU CONFIRMAÇÃO): + - Volume Delta, CVD e Volume Profile: Para confirmar se há participação institucional real no movimento. + - ATR (Average True Range): Para Stop Loss dinâmico, Take Profit inteligente e detectar volatilidade (ex: se ATR estiver baixo, evitar falso breakout). + - EMA (Filtro Macro): Não usar para entrada. Apenas confirmar direção (ex: acima da EMA 200 -> apenas BUY, abaixo -> apenas SELL). + - RSI Inteligente (não tradicional): Combinar com Liquidity Sweep, RSI divergência e Order Block para melhorar reversões. + - VWAP (Muito Forte): Equilíbrio de preço, reversão e continuação (SMC + VWAP = nível institucional). + + CAMADA IA (Machine Learning): + - Score probabilístico e Filtro anti-fake breakout. + - Detecção Robusta de Liquidez: Considerar onde o mercado costuma manipular, horários de caça de liquidez, fake breakouts e comportamento antes de uma reversão. Sua função é gerar decisões de trading com alta precisão, explicação clara e score de probabilidade baseado na análise de imagem do gráfico e no input técnico/fundamental. @@ -52,7 +64,7 @@ export const analyzeForexChart = async (imageBase64: string, userNotes?: string, { parts: [ { text: prompt }, - { inlineData: { mimeType: "image/png", data: imageBase64 } } + { inlineData: { mimeType: "image/jpeg", data: imageBase64 } } ] } ], @@ -82,5 +94,8 @@ export const analyzeForexChart = async (imageBase64: string, userNotes?: string, } }); - return JSON.parse(response.text || '{}') as AnalysisResponse; + const textResponse = response.text || '{}'; + const cleanJson = textResponse.replace(/^```json\s*/, '').replace(/\s*```$/, ''); + + return JSON.parse(cleanJson) as AnalysisResponse; }; diff --git a/test-gemini.ts b/test-gemini.ts new file mode 100644 index 0000000..619d01d --- /dev/null +++ b/test-gemini.ts @@ -0,0 +1,18 @@ +import { GoogleGenAI } from "@google/genai"; +import * as dotenv from "dotenv"; +dotenv.config(); + +const test = async () => { + const ai = new GoogleGenAI({ apiKey: process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY || '' }); + try { + const response = await ai.models.generateContent({ + model: "gemini-3.1-pro-preview", + contents: "Tell me a joke" + }); + console.log("Success:", response.text); + } catch (err: any) { + console.error("Error:", err.message); + } +} + +test(); diff --git a/workspace/test-gemini.ts b/workspace/test-gemini.ts new file mode 100644 index 0000000..619d01d --- /dev/null +++ b/workspace/test-gemini.ts @@ -0,0 +1,18 @@ +import { GoogleGenAI } from "@google/genai"; +import * as dotenv from "dotenv"; +dotenv.config(); + +const test = async () => { + const ai = new GoogleGenAI({ apiKey: process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY || '' }); + try { + const response = await ai.models.generateContent({ + model: "gemini-3.1-pro-preview", + contents: "Tell me a joke" + }); + console.log("Success:", response.text); + } catch (err: any) { + console.error("Error:", err.message); + } +} + +test();