gs-port / src /components /StockSearch.jsx
Scribbler310's picture
Upload folder using huggingface_hub
95b1ab8 verified
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>
);
}