| import { useState, useEffect, useRef } from 'react'; |
| import LandingPage from './pages/LandingPage'; |
| import Dashboard from './pages/Dashboard'; |
| import UpgradePage from './pages/UpgradePage'; |
| import { X } from 'lucide-react'; |
| import './App.css'; |
|
|
| |
| declare global { |
| interface Window { |
| google?: any; |
| Razorpay?: any; |
| } |
| } |
|
|
| |
| const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; |
| const GQL_HTTP_URL = `${API_URL}/graphql`; |
| const GQL_WS_URL = `${API_URL.replace(/^http/, 'ws')}/graphql`; |
| const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID || 'dummy-client-id.apps.googleusercontent.com'; |
|
|
| |
| async function graphqlRequest(query: string, variables: any = {}) { |
| const token = localStorage.getItem('quantiq_jwt'); |
| const headers: Record<string, string> = { |
| 'Content-Type': 'application/json', |
| }; |
| if (token) { |
| headers['Authorization'] = `Bearer ${token}`; |
| } |
|
|
| const response = await fetch(GQL_HTTP_URL, { |
| method: 'POST', |
| headers, |
| body: JSON.stringify({ query, variables }), |
| }); |
| |
| const result = await response.json(); |
| if (result.errors) { |
| throw new Error(result.errors[0].message); |
| } |
| return result.data; |
| } |
|
|
| export default function App() { |
| |
| const [token, setToken] = useState<string | null>(localStorage.getItem('quantiq_jwt')); |
| const [user, setUser] = useState<any>(null); |
| const [currentView, setCurrentView] = useState<'landing' | 'dashboard' | 'upgrade'>(localStorage.getItem('quantiq_jwt') ? 'dashboard' : 'landing'); |
| |
| |
| const [watchlist, setWatchlist] = useState<string[]>([]); |
| const [watchlistQuotes, setWatchlistQuotes] = useState<Record<string, { price: number; changePercent: number }>>({}); |
| const [activeTicker, setActiveTicker] = useState<string>(''); |
| const [chartData, setChartData] = useState<any[]>([]); |
| const [activeStats, setActiveStats] = useState<any>(null); |
| const [alerts, setAlerts] = useState<any[]>([]); |
| const [chartRange, setChartRange] = useState<string>('1d'); |
| const [indices, setIndices] = useState<any[]>([]); |
| const [toasts, setToasts] = useState<Array<{ id: string; message: string; type: 'success' | 'alert' }>>([]); |
|
|
| const alertsRef = useRef<any[]>([]); |
| useEffect(() => { |
| alertsRef.current = alerts; |
| }, [alerts]); |
|
|
| const addToast = (message: string, type: 'success' | 'alert' = 'success') => { |
| const id = Math.random().toString(36).substring(2, 9); |
| setToasts(prev => [...prev, { id, message, type }]); |
| setTimeout(() => { |
| setToasts(prev => prev.filter(t => t.id !== id)); |
| }, 6000); |
| }; |
| |
| |
| const [insight, setInsight] = useState<any>(null); |
| const [savedStrategies, setSavedStrategies] = useState<any[]>([]); |
| const [loadingInsight, setLoadingInsight] = useState<boolean>(false); |
| const [insightError, setInsightError] = useState<string | null>(null); |
|
|
|
|
|
|
| const fetchWatchlistData = async () => { |
| try { |
| const watchlistData = await graphqlRequest(` |
| query { |
| watchlist { |
| ticker |
| price |
| changePercent |
| } |
| } |
| `); |
| if (watchlistData && watchlistData.watchlist) { |
| const tickers = watchlistData.watchlist.map((w: any) => w.ticker); |
| setWatchlist(tickers); |
| |
| const quotes: Record<string, { price: number; changePercent: number }> = {}; |
| watchlistData.watchlist.forEach((w: any) => { |
| if (w.price !== null && w.price !== undefined) { |
| quotes[w.ticker] = { price: w.price, changePercent: w.changePercent || 0.0 }; |
| } |
| }); |
| setWatchlistQuotes(quotes); |
| return tickers; |
| } |
| } catch (err) { |
| console.error('Error fetching watchlist data:', err); |
| } |
| return []; |
| }; |
|
|
| |
| useEffect(() => { |
| |
| const rzpScript = document.createElement('script'); |
| rzpScript.src = 'https://checkout.razorpay.com/v1/checkout.js'; |
| rzpScript.async = true; |
| document.body.appendChild(rzpScript); |
|
|
| return () => { |
| document.body.removeChild(rzpScript); |
| }; |
| }, []); |
|
|
| const fetchStrategyHistory = async () => { |
| if (!token) return; |
| try { |
| const data = await graphqlRequest(` |
| query { |
| savedStrategies { |
| id |
| ticker |
| bullishProbability |
| reason |
| createdAt |
| } |
| } |
| `); |
| setSavedStrategies(data.savedStrategies || []); |
| } catch (err) { |
| console.error('Failed to load strategy history', err); |
| } |
| }; |
|
|
| |
| useEffect(() => { |
| if (!token) return; |
|
|
| const loadInitialData = async () => { |
| try { |
| |
| const profileData = await graphqlRequest(` |
| query { |
| me { |
| id |
| email |
| fullName |
| pictureUrl |
| credits |
| createdAt |
| subscriptionTier |
| messagesRemaining |
| monthlyMessagesUsed |
| lastBillingDate |
| } |
| } |
| `); |
| setUser(profileData.me); |
|
|
| |
| const tickers = await fetchWatchlistData(); |
| if (tickers.length > 0) { |
| |
| setActiveTicker(tickers[0]); |
| } else { |
| setActiveTicker(''); |
| } |
|
|
| |
| const alertsData = await graphqlRequest(` |
| query { |
| alerts { |
| id |
| ticker |
| targetPrice |
| condition |
| isActive |
| } |
| } |
| `); |
| setAlerts(alertsData.alerts); |
| await fetchStrategyHistory(); |
| } catch (err: any) { |
| console.error('Failed to load profile / user data:', err); |
| |
| |
| const msg = err?.message?.toLowerCase() || ''; |
| if (msg.includes('unauthorized') || msg.includes('invalid token') || msg.includes('not authenticated')) { |
| handleLogout(); |
| } |
| |
| } |
| }; |
|
|
| loadInitialData(); |
| }, [token]); |
|
|
| |
| useEffect(() => { |
| if (!token || !activeTicker) return; |
|
|
| const fetchHistory = async () => { |
| try { |
| const historyData = await graphqlRequest(` |
| query GetHistory($ticker: String!, $range: String!) { |
| stockHistory(ticker: $ticker, range: $range) { |
| timestamp |
| open |
| high |
| low |
| close |
| volume |
| } |
| } |
| `, { ticker: activeTicker, range: chartRange }); |
| |
| const history = historyData.stockHistory || []; |
| if (history.length > 0) { |
| const prices = history.map((h: any) => h.close); |
| const high = Math.max(...prices); |
| const low = Math.min(...prices); |
| const open = history[0].open || prices[0]; |
| const close = history[history.length - 1].close; |
| const volume = history.reduce((sum: number, h: any) => sum + (h.volume || 0), 0); |
| const change = close - open; |
| const changePercent = (change / open) * 100; |
| |
| setActiveStats({ |
| open, |
| high, |
| low, |
| close, |
| volume, |
| change, |
| changePercent |
| }); |
| } else { |
| setActiveStats(null); |
| } |
|
|
| const formatted = history.map((h: any) => { |
| const dateObj = new Date(h.timestamp); |
| let timeLabel = ''; |
| if (chartRange === '1d') { |
| timeLabel = dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
| } else if (chartRange === '5d') { |
| timeLabel = dateObj.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' ' + dateObj.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
| } else if (chartRange === '1y' || chartRange === '5y' || chartRange === 'max') { |
| timeLabel = dateObj.toLocaleDateString([], { year: '2-digit', month: 'short' }); |
| } else { |
| timeLabel = dateObj.toLocaleDateString([], { month: 'short', day: 'numeric' }); |
| } |
| const closeVal = parseFloat(h.close.toFixed(2)); |
| const openVal = parseFloat((h.open !== null && h.open !== undefined ? h.open : h.close).toFixed(2)); |
| const highVal = parseFloat((h.high !== null && h.high !== undefined ? h.high : h.close).toFixed(2)); |
| const lowVal = parseFloat((h.low !== null && h.low !== undefined ? h.low : h.close).toFixed(2)); |
| return { |
| time: timeLabel, |
| price: closeVal, |
| open: openVal, |
| high: highVal, |
| low: lowVal, |
| close: closeVal, |
| range: [openVal, closeVal], |
| }; |
| }); |
| setChartData(formatted); |
| } catch (err) { |
| console.error('Failed to load stock history', err); |
| } |
| }; |
|
|
| fetchHistory(); |
| }, [token, activeTicker, chartRange]); |
|
|
| |
| useEffect(() => { |
| setInsight(null); |
| setInsightError(null); |
| }, [activeTicker]); |
|
|
| |
| useEffect(() => { |
| if (!token || !activeTicker) return; |
|
|
| const ws = new WebSocket(GQL_WS_URL, 'graphql-transport-ws'); |
| let keepAliveInterval: any; |
|
|
| ws.onopen = () => { |
| |
| ws.send(JSON.stringify({ type: 'connection_init', payload: {} })); |
| }; |
|
|
| ws.onmessage = (event) => { |
| const msg = JSON.parse(event.data); |
| |
| if (msg.type === 'connection_ack') { |
| |
| ws.send(JSON.stringify({ |
| id: 'stock-tick-sub', |
| type: 'subscribe', |
| payload: { |
| query: ` |
| subscription LiveTicks($ticker: String!) { |
| streamStockTicks(ticker: $ticker) { |
| ticker |
| price |
| timestamp |
| } |
| } |
| `, |
| variables: { ticker: activeTicker } |
| } |
| })); |
|
|
| |
| keepAliveInterval = setInterval(() => { |
| ws.send(JSON.stringify({ type: 'ping' })); |
| }, 20000); |
| } |
|
|
| if (msg.type === 'next' && msg.id === 'stock-tick-sub') { |
| const tick = msg.payload.data?.streamStockTicks; |
| if (!tick) return; |
|
|
| |
| const currentPrice = tick.price; |
| alertsRef.current.forEach((alert) => { |
| if (alert.ticker === tick.ticker && alert.isActive) { |
| const isAboveTriggered = alert.condition === 'above' && currentPrice >= alert.targetPrice; |
| const isBelowTriggered = alert.condition === 'below' && currentPrice <= alert.targetPrice; |
|
|
| if (isAboveTriggered || isBelowTriggered) { |
| addToast( |
| `🔔 Alert Triggered: ${tick.ticker} crossed ${alert.condition.toUpperCase()} $${alert.targetPrice.toFixed(2)} (Current: $${currentPrice.toFixed(2)})`, |
| 'alert' |
| ); |
| |
| handleDeactivateAlert(alert.id); |
| } |
| } |
| }); |
| |
| const tickTime = new Date(tick.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
| |
| setChartData((prevData) => { |
| const updated = [...prevData]; |
| const last = updated[updated.length - 1]; |
| const newPrice = parseFloat(tick.price.toFixed(2)); |
| |
| if (last && last.time === tickTime) { |
| const openVal = last.open !== undefined ? last.open : newPrice; |
| const highVal = Math.max(last.high !== undefined ? last.high : newPrice, newPrice); |
| const lowVal = Math.min(last.low !== undefined ? last.low : newPrice, newPrice); |
| |
| updated[updated.length - 1] = { |
| time: tickTime, |
| price: newPrice, |
| open: openVal, |
| high: highVal, |
| low: lowVal, |
| close: newPrice, |
| range: [openVal, newPrice] |
| }; |
| return updated; |
| } else { |
| const nextList = [ |
| ...updated, |
| { |
| time: tickTime, |
| price: newPrice, |
| open: newPrice, |
| high: newPrice, |
| low: newPrice, |
| close: newPrice, |
| range: [newPrice, newPrice] |
| } |
| ]; |
| |
| if (nextList.length > 35) nextList.shift(); |
| return nextList; |
| } |
| }); |
| } |
| }; |
|
|
| return () => { |
| clearInterval(keepAliveInterval); |
| if (ws.readyState === WebSocket.OPEN) { |
| try { |
| ws.send(JSON.stringify({ id: 'stock-tick-sub', type: 'complete' })); |
| } catch (_) { } |
| } |
| ws.close(); |
| }; |
| }, [token, activeTicker]); |
|
|
| |
| useEffect(() => { |
| const fetchIndices = async () => { |
| try { |
| const response = await fetch(`${API_URL}/api/v1/stocks/indices`); |
| if (response.ok) { |
| const data = await response.json(); |
| setIndices(data); |
| } |
| } catch (err) { |
| console.error('Failed to fetch global indices:', err); |
| } |
| }; |
| |
| fetchIndices(); |
| const interval = setInterval(fetchIndices, 60000); |
| return () => clearInterval(interval); |
| }, []); |
|
|
| |
| const handleAuthSuccess = (accessToken: string) => { |
| localStorage.setItem('quantiq_jwt', accessToken); |
| setToken(accessToken); |
| setCurrentView('dashboard'); |
| }; |
|
|
| const handleGoogleCredentialResponse = async (response: any) => { |
| try { |
| const idToken = response.credential; |
| |
| let res: any = null; |
| for (let attempt = 1; attempt <= 5; attempt++) { |
| const r = await fetch(`${API_URL}/api/v1/auth/google`, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ token_id: idToken }), |
| }); |
| const ct = r.headers.get('content-type') || ''; |
| if (r.status >= 500 && attempt < 5) { |
| await new Promise(resolve => setTimeout(resolve, 800 + Math.random() * 800)); |
| continue; |
| } |
| if (!ct.includes('application/json')) break; |
| res = await r.json(); |
| break; |
| } |
|
|
| if (res?.access_token) { |
| handleAuthSuccess(res.access_token); |
| } |
| } catch (err) { |
| console.error('Google Auth server exchange error:', err); |
| } |
| }; |
|
|
|
|
|
|
| const handleLogout = () => { |
| localStorage.removeItem('quantiq_jwt'); |
| setToken(null); |
| setUser(null); |
| setInsight(null); |
| setCurrentView('landing'); |
| }; |
|
|
| |
| const handleAddWatchlist = async (ticker: string) => { |
| const tickerUpper = ticker.toUpperCase(); |
| await graphqlRequest(` |
| mutation AddToWatchlist($ticker: String!) { |
| addWatchlist(ticker: $ticker) { |
| ticker |
| } |
| } |
| `, { ticker: tickerUpper }); |
|
|
| await fetchWatchlistData(); |
| setActiveTicker(tickerUpper); |
| }; |
|
|
| const handleRemoveWatchlist = async (ticker: string) => { |
| await graphqlRequest(` |
| mutation RemoveFromWatchlist($ticker: String!) { |
| removeWatchlist(ticker: $ticker) |
| } |
| `, { ticker }); |
|
|
| const filtered = watchlist.filter(w => w !== ticker); |
| setWatchlist(filtered); |
| if (activeTicker === ticker && filtered.length > 0) { |
| setActiveTicker(filtered[0]); |
| } |
| }; |
|
|
| |
| const handleCreateAlert = async (targetPrice: number, condition: string) => { |
| if (!activeTicker) return; |
| const alertData = await graphqlRequest(` |
| mutation CreateAlert($ticker: String!, $targetPrice: Float!, $condition: String!) { |
| createAlert(ticker: $ticker, targetPrice: $targetPrice, condition: $condition) { |
| id |
| ticker |
| targetPrice |
| condition |
| isActive |
| } |
| } |
| `, { |
| ticker: activeTicker, |
| targetPrice, |
| condition |
| }); |
|
|
| setAlerts([alertData.createAlert, ...alerts]); |
| }; |
|
|
| const handleDeactivateAlert = async (alertId: string) => { |
| await graphqlRequest(` |
| mutation DeactivateAlert($id: UUID!) { |
| deactivateAlert(alertId: $id) |
| } |
| `, { id: alertId }); |
|
|
| setAlerts(alerts.map(a => a.id === alertId ? { ...a, isActive: false } : a)); |
| }; |
|
|
| |
| const triggerAIInsight = async (tradingStyle?: string, riskTolerance?: string) => { |
| if (!user || (user.credits <= 0 && user.email !== 'karanshelar8775@gmail.com')) { |
| setCurrentView('upgrade'); |
| return; |
| } |
|
|
| setLoadingInsight(true); |
| setInsightError(null); |
| setInsight(null); |
|
|
| try { |
| const insightData = await graphqlRequest(` |
| mutation GetAIInsight($ticker: String!, $tradingStyle: String, $riskTolerance: String) { |
| getAiInsight(ticker: $ticker, tradingStyle: $tradingStyle, riskTolerance: $riskTolerance) { |
| ticker |
| bullishProbability |
| reason |
| creditsRemaining |
| } |
| } |
| `, { ticker: activeTicker, tradingStyle, riskTolerance }); |
|
|
| setInsight(insightData.getAiInsight); |
| setUser({ ...user, credits: insightData.getAiInsight.creditsRemaining }); |
| await fetchStrategyHistory(); |
| } catch (err: any) { |
| console.error('QuantIQ AI Analyst Agent loop error:', err); |
| setInsightError(err.message || 'Error compiling strategy.'); |
| } finally { |
| setLoadingInsight(false); |
| } |
| }; |
|
|
| |
| const handlePayment = async (amountInRupees: number) => { |
| if (!user) return; |
| try { |
| const orderData = await graphqlRequest(` |
| mutation CreateOrder($amount: Int!) { |
| createPaymentOrder(amount: $amount) { |
| orderId |
| amount |
| currency |
| } |
| } |
| `, { amount: amountInRupees }); |
|
|
| const options = { |
| key: import.meta.env.VITE_RAZORPAY_KEY_ID || 'rzp_test_dummykeyid123', |
| amount: orderData.createPaymentOrder.amount, |
| currency: orderData.createPaymentOrder.currency, |
| name: 'QuantIQ Platform', |
| description: `Purchase ${amountInRupees} credits for AI Insights`, |
| order_id: orderData.createPaymentOrder.orderId, |
| handler: async function () { |
| |
| setTimeout(async () => { |
| try { |
| const updatedProfile = await graphqlRequest(` |
| query { |
| me { |
| credits |
| subscriptionTier |
| messagesRemaining |
| monthlyMessagesUsed |
| lastBillingDate |
| } |
| } |
| `); |
| setUser((prev: any) => prev ? { |
| ...prev, |
| credits: updatedProfile.me.credits, |
| subscriptionTier: updatedProfile.me.subscriptionTier, |
| messagesRemaining: updatedProfile.me.messagesRemaining, |
| monthlyMessagesUsed: updatedProfile.me.monthlyMessagesUsed, |
| lastBillingDate: updatedProfile.me.lastBillingDate |
| } : null); |
| } catch (e) { |
| console.error('Refresh credits error:', e); |
| } |
| }, 2000); |
| |
| setCurrentView('dashboard'); |
| }, |
| prefill: { |
| name: user.fullName || 'QuantIQ User', |
| email: user.email, |
| }, |
| theme: { |
| color: '#a154ff', |
| }, |
| }; |
|
|
| const rzpInstance = new window.Razorpay(options); |
| rzpInstance.open(); |
| } catch (err) { |
| console.error('Payment checkout failed:', err); |
| } |
| }; |
|
|
| |
| if (!token || currentView === 'landing') { |
| return ( |
| <LandingPage |
| onGoogleLogin={handleGoogleCredentialResponse} |
| googleClientId={GOOGLE_CLIENT_ID} |
| onAuthSuccess={handleAuthSuccess} |
| apiUrl={API_URL} |
| user={user} |
| onGoToDashboard={() => setCurrentView('dashboard')} |
| /> |
| ); |
| } |
|
|
| if (currentView === 'upgrade') { |
| return ( |
| <UpgradePage |
| user={user} |
| onBack={() => setCurrentView('dashboard')} |
| onSelectPackage={handlePayment} |
| /> |
| ); |
| } |
|
|
| return ( |
| <> |
| <Dashboard |
| user={user} |
| watchlist={watchlist} |
| watchlistQuotes={watchlistQuotes} |
| activeTicker={activeTicker} |
| chartData={chartData} |
| activeStats={activeStats} |
| alerts={alerts} |
| insight={insight} |
| savedStrategies={savedStrategies} |
| loadingInsight={loadingInsight} |
| insightError={insightError} |
| chartRange={chartRange} |
| onRangeChange={setChartRange} |
| onSelectTicker={setActiveTicker} |
| onAddTicker={handleAddWatchlist} |
| onRemoveTicker={handleRemoveWatchlist} |
| onCreateAlert={handleCreateAlert} |
| onDeactivateAlert={handleDeactivateAlert} |
| onTriggerInsight={triggerAIInsight} |
| onResetInsight={() => { setInsight(null); setInsightError(null); }} |
| onOpenRecharge={() => setCurrentView('upgrade')} |
| onLogout={handleLogout} |
| onLogoClick={() => setCurrentView('landing')} |
| onAvatarUpload={(newUrl) => setUser((prev: any) => prev ? { ...prev, pictureUrl: newUrl } : null)} |
| indices={indices} |
| /> |
| |
| {/* Toast Notification Stack */} |
| <div className="toast-container"> |
| {toasts.map((toast) => ( |
| <div |
| key={toast.id} |
| className={`toast-notification ${toast.type === 'alert' ? 'toast-alert' : 'toast-success'}`} |
| > |
| <span style={{ fontSize: '13px', fontWeight: 600, flex: 1, textAlign: 'left' }}>{toast.message}</span> |
| <button |
| onClick={() => setToasts(prev => prev.filter(t => t.id !== toast.id))} |
| style={{ background: 'none', border: 'none', color: 'rgba(255,255,255,0.6)', cursor: 'pointer', display: 'flex', alignItems: 'center', padding: '2px', outline: 'none' }} |
| > |
| <X size={14} /> |
| </button> |
| </div> |
| ))} |
| </div> |
| </> |
| ); |
| } |
|
|