feat: Enhance Gemini analysis and image handling

Integrates Gemini 3.1 Pro for improved Forex chart analysis, incorporating detailed instructions on Smart Money Concepts, liquidity, and various confirmation indicators like Volume Delta, ATR, EMA, RSI, and VWAP.

Also includes image compression and cleanup utilities for testimonial uploads to manage storage efficiently. Firestore rules for signal data have been updated to accommodate longer strings for pair and timeframe.
This commit is contained in:
desartstudio95
2026-05-12 07:56:18 +02:00
parent 11981e28b0
commit a72d42c6d8
7 changed files with 268 additions and 16 deletions
+138 -8
View File
@@ -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<string | null>(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<number | null>(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
<span className="text-[8px] uppercase font-black tracking-widest opacity-60">Decisão QuantScan</span>
</div>
{mockPrice !== null && (
<div className="flex flex-col items-center justify-center p-2 mb-2 bg-black/40 rounded border border-white/5">
<span className="text-[9px] uppercase tracking-widest text-zinc-500 font-bold">Preço de Mercado (Simulado)</span>
<span className="font-mono text-lg font-black text-white">{mockPrice.toFixed(5)}</span>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between p-3 glass-card bg-transparent border-white/5">
<div className="flex items-center gap-2">
<Target size={14} className="text-zinc-600" />
<span className="text-[9px] font-black text-zinc-500 uppercase">ENTRADA</span>
</div>
<span className="font-mono font-black text-sm">{result.entry}</span>
<div className="flex items-center gap-2">
<span className="font-mono font-black text-sm">{result.entry}</span>
<button
onClick={() => toggleAlert('Entrada', result.entry)}
className={cn(
"p-1.5 rounded transition-colors",
alerts.some(a => a.type === 'Entrada') ? "text-yellow-500 bg-yellow-500/20" : "text-zinc-500 hover:text-yellow-500 hover:bg-white/5"
)}
>
{alerts.some(a => a.type === 'Entrada') ? <BellRing size={14} /> : <Bell size={14} />}
</button>
</div>
</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-brand-red/50" />
<span className="text-[9px] font-black text-zinc-500 uppercase">STOP LOSS</span>
</div>
<span className="font-mono font-black text-sm text-brand-red">{result.stopLoss}</span>
<div className="flex items-center gap-2">
<span className="font-mono font-black text-sm text-brand-red">{result.stopLoss}</span>
<button
onClick={() => toggleAlert('Stop Loss', result.stopLoss)}
className={cn(
"p-1.5 rounded transition-colors",
alerts.some(a => a.type === 'Stop Loss') ? "text-yellow-500 bg-yellow-500/20" : "text-zinc-500 hover:text-yellow-500 hover:bg-white/5"
)}
>
{alerts.some(a => a.type === 'Stop Loss') ? <BellRing size={14} /> : <Bell size={14} />}
</button>
</div>
</div>
<div className="flex items-center justify-between p-3 glass-card bg-transparent border-white/5">
<div className="flex items-center gap-2">
<ShieldCheck size={14} className="text-green-500/50" />
<span className="text-[9px] font-black text-zinc-500 uppercase">TAKE PROFIT</span>
</div>
<span className="font-mono font-black text-sm text-green-500">{result.takeProfit}</span>
<div className="flex items-center gap-2">
<span className="font-mono font-black text-sm text-green-500">{result.takeProfit}</span>
<button
onClick={() => toggleAlert('Take Profit', result.takeProfit)}
className={cn(
"p-1.5 rounded transition-colors",
alerts.some(a => a.type === 'Take Profit') ? "text-yellow-500 bg-yellow-500/20" : "text-zinc-500 hover:text-yellow-500 hover:bg-white/5"
)}
>
{alerts.some(a => a.type === 'Take Profit') ? <BellRing size={14} /> : <Bell size={14} />}
</button>
</div>
</div>
</div>
</div>
+8 -2
View File
@@ -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<ImageUploaderProps> = ({ 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);