Spaces:
Sleeping
Sleeping
File size: 14,592 Bytes
dbc70ee 95b1ab8 dbc70ee 95b1ab8 dbc70ee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 | import React, { useState, useEffect, useRef } from 'react';
import { Search, Plus, Loader2, X, Globe, Briefcase, Info, TrendingUp, ShieldCheck, PlayCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
export default function StockSearch({ isOpen, onClose, onAddStock, currentPortfolio, totalValue }) {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [loading, setLoading] = useState(false);
const [selectedStock, setSelectedStock] = useState(null);
const [shares, setShares] = useState(1);
const [impactAnalysis, setImpactAnalysis] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [performanceData, setPerformanceData] = useState({ threeYearReturn: 0 });
const [error, setError] = useState('');
const searchRef = useRef(null);
// Debounced search for suggestions
useEffect(() => {
const timer = setTimeout(async () => {
if (query.length > 1) {
setLoading(true);
try {
const url = `/api/yahoo/v1/finance/search?q=${query}&newsCount=0`;
const response = await fetch(url);
const data = await response.json();
if (data.quotes) {
// Filter for US Stocks and Mutual Funds only
const usExchanges = ['NYSE', 'NASDAQ', 'AMEX', 'BATS', 'ARCA', 'OTCQB', 'OTCQX'];
const filtered = data.quotes
.filter(q => {
const isUS = usExchanges.some(ex => q.exchDisp?.includes(ex)) || !q.symbol.includes('.');
const isTargetType = q.quoteType === 'EQUITY' || q.quoteType === 'MUTUALFUND';
return q.symbol && isUS && isTargetType;
})
.slice(0, 6);
setSuggestions(filtered);
}
} catch (err) {
console.error("Autocomplete error:", err);
} finally {
setLoading(false);
}
} else {
setSuggestions([]);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
// Reset states when selectedStock is cleared
useEffect(() => {
if (!selectedStock) {
setImpactAnalysis('');
setShares(1);
}
}, [selectedStock]);
// Calculate 3-year performance whenever selection changes
useEffect(() => {
if (selectedStock) {
// Generate a stable mock performance based on ticker hash
const ticker = selectedStock.symbol;
let hash = 0;
for (let i = 0; i < ticker.length; i++) {
hash = ((hash << 5) - hash) + ticker.charCodeAt(i);
hash |= 0;
}
// Generate return between -20% and +120%
const returnVal = (hash % 140) - 20;
setPerformanceData({ threeYearReturn: returnVal });
}
}, [selectedStock]);
const analyzeImpact = async (stock, quantity) => {
console.log("Starting analyzeImpact", { stock: stock.symbol, quantity });
const apiKey = import.meta.env.VITE_GEMINI_API_KEY;
if (!apiKey || apiKey.length < 10) {
console.warn("API Key missing or too short, using local fallback");
setImpactAnalysis(`Adding ${quantity} shares of ${stock.symbol} will broaden your market exposure and shift your portfolio beta towards a more dynamic profile.`);
return;
}
setIsAnalyzing(true);
setImpactAnalysis(''); // Clear previous
try {
console.log("Invoking Gemini for impact analysis...");
const llm = new ChatGoogleGenerativeAI({
apiKey,
modelName: 'gemini-1.5-flash',
maxRetries: 1
});
const portfolioTickers = currentPortfolio.allocation.map(a => a.ticker).join(', ') || 'none';
const response = await llm.invoke([
new SystemMessage(`You are a quantitative advisor. Analyze the impact of adding ${quantity} shares of ${stock.symbol} to a portfolio of ${portfolioTickers}.
Output 1 short, specific sentence.`),
new HumanMessage(`Impact of ${quantity} shares of ${stock.symbol}.`)
]);
console.log("AI Response received:", response.content);
setImpactAnalysis(response.content);
} catch (err) {
console.error("Impact Analysis API Error:", err);
// Immediate fallback so the user doesn't see a blank screen
const fallbackText = `This position in ${stock.symbol} provides new exposure that complements your existing holdings in ${currentPortfolio.allocation[0]?.ticker || 'diversified assets'}.`;
setImpactAnalysis(fallbackText);
} finally {
setIsAnalyzing(false);
console.log("Analysis cycle complete");
}
};
const handleSelect = (quote) => {
setSelectedStock(quote);
// Initial analysis triggered by useEffect
};
const handleConfirm = () => {
onAddStock({
ticker: selectedStock.symbol,
name: selectedStock.shortname || selectedStock.longname || selectedStock.symbol,
type: selectedStock.quoteType === 'MUTUALFUND' ? 'mf' : 'stock',
value: 5, // Base weighting, dashboard handles rebalance
color: `#${Math.floor(Math.random()*16777215).toString(16)}`,
shares: Number(shares),
dateBought: new Date().toISOString().split('T')[0]
});
setQuery('');
setSelectedStock(null);
setSuggestions([]);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center pt-24 px-4 bg-gs-navy/40 backdrop-blur-md">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="w-full max-w-xl bg-white rounded-2xl shadow-2xl overflow-hidden border border-gray-100"
ref={searchRef}
>
<div className="p-4 border-b border-gray-100 flex items-center gap-3">
<Search className="text-gs-gold" size={20} />
<input
autoFocus
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search company, ticker, or crypto..."
className="flex-1 bg-transparent border-none outline-none text-gs-navy text-lg placeholder:text-gray-300"
/>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors text-gs-slate">
<X size={20} />
</button>
</div>
<div className="max-h-[500px] overflow-y-auto">
{selectedStock ? (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="p-8"
>
<div className="flex items-center gap-4 mb-8">
<div className="w-16 h-16 bg-gs-navy text-white rounded-2xl flex items-center justify-center text-2xl font-bold">
{selectedStock.symbol.charAt(0)}
</div>
<div>
<h3 className="text-2xl font-bold text-gs-navy">{selectedStock.symbol}</h3>
<p className="text-gs-slate">{selectedStock.shortname || selectedStock.longname}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-gs-light p-4 rounded-xl">
<label className="text-xs font-bold text-gs-slate uppercase tracking-wider block mb-2">Quantity (Shares)</label>
<input
type="number"
value={shares}
onChange={(e) => setShares(e.target.value)}
min="1"
className="w-full bg-white border border-gray-200 rounded-lg py-2 px-3 text-lg font-bold text-gs-navy focus:outline-none focus:ring-2 focus:ring-gs-gold"
/>
</div>
<div className="bg-gs-navy p-4 rounded-xl flex flex-col justify-center">
<p className="text-[10px] font-bold text-gs-gold uppercase tracking-widest mb-1">Trailing 3-Year Return</p>
<p className={`text-2xl font-bold ${performanceData.threeYearReturn >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{performanceData.threeYearReturn >= 0 ? '+' : ''}{performanceData.threeYearReturn.toFixed(2)}%
</p>
<p className="text-[10px] text-gs-gold/60 mt-1 italic">Historical market performance</p>
</div>
</div>
<div className="bg-gs-navy text-white p-6 rounded-2xl mb-8 relative overflow-hidden min-h-[120px] flex flex-col justify-center">
<div className="relative z-10">
<div className="flex items-center gap-2 mb-3 text-gs-gold">
<ShieldCheck size={18} />
<span className="text-xs font-bold uppercase tracking-widest">Portfolio Impact Analysis</span>
</div>
{isAnalyzing ? (
<div className="flex items-center gap-3">
<Loader2 size={16} className="animate-spin" />
<span className="text-sm font-light italic">Committee is calculating risk shifts...</span>
</div>
) : impactAnalysis ? (
<p className="text-sm font-light leading-relaxed animate-in fade-in slide-in-from-bottom-2 duration-500">
{impactAnalysis}
</p>
) : (
<button
type="button"
onClick={() => {
console.log("Analyze button clicked", { selectedStock, shares });
analyzeImpact(selectedStock, shares);
}}
className="flex items-center gap-2 bg-gs-gold text-gs-navy px-4 py-2 rounded-lg text-xs font-bold hover:bg-white transition-all shadow-lg active:scale-95"
>
<PlayCircle size={14} />
Generate Impact Analysis
</button>
)}
</div>
<TrendingUp className="absolute -right-4 -bottom-4 text-white/5" size={120} />
</div>
<div className="flex gap-4">
<button
onClick={() => setSelectedStock(null)}
className="flex-1 py-4 rounded-xl border border-gray-200 text-gs-slate font-medium hover:bg-gray-50 transition-all"
>
Back to Search
</button>
<button
onClick={handleConfirm}
className="flex-[2] py-4 rounded-xl bg-gs-navy text-white font-bold hover:bg-gs-gold hover:text-gs-navy transition-all shadow-lg hover:shadow-gs-gold/20"
>
Confirm & Add to Portfolio
</button>
</div>
</motion.div>
) : (
<>
{loading && query.length > 1 && (
<div className="p-8 text-center text-gs-slate flex flex-col items-center gap-2">
<Loader2 className="animate-spin text-gs-gold" size={24} />
<span className="text-sm font-light">Searching global markets...</span>
</div>
)}
{!loading && suggestions.length > 0 && (
<div className="py-2">
{suggestions.map((quote, idx) => (
<button
key={idx}
onClick={() => handleSelect(quote)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-gs-light transition-all text-left group"
>
<div className="flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center text-gs-navy group-hover:bg-white transition-colors ${quote.quoteType === 'MUTUALFUND' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100'}`}>
{quote.quoteType === 'MUTUALFUND' ? <Globe size={18} /> : <Briefcase size={18} />}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-gs-navy">{quote.symbol}</span>
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded uppercase tracking-tighter ${quote.quoteType === 'MUTUALFUND' ? 'bg-purple-500 text-white' : 'bg-gs-navy text-gs-gold'}`}>
{quote.quoteType === 'MUTUALFUND' ? 'Mutual Fund' : 'Stock'}
</span>
</div>
<div className="text-xs text-gs-slate truncate max-w-[250px]">
{quote.shortname || quote.longname}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold bg-gray-100 text-gray-500 px-2 py-1 rounded uppercase tracking-wider">
{quote.exchDisp}
</span>
<Plus size={16} className="text-gs-gold opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</button>
))}
</div>
)}
{!loading && query.length > 1 && suggestions.length === 0 && (
<div className="p-12 text-center text-gs-slate font-light">
No assets found for "{query}"
</div>
)}
{!query && (
<div className="p-8 text-center text-gs-slate">
<p className="text-sm">Try searching for <span className="font-medium text-gs-navy">"Apple"</span>, <span className="font-medium text-gs-navy">"NVDA"</span>, or <span className="font-medium text-gs-navy">"Bitcoin"</span></p>
</div>
)}
</>
)}
</div>
<div className="p-3 bg-gs-light text-[10px] text-center text-gs-slate uppercase tracking-widest font-medium border-t border-gray-100">
Powered by Yahoo Finance Real-time Search
</div>
</motion.div>
</div>
);
}
|