diff --git a/backend/live/emailService.js b/backend/live/emailService.js new file mode 100644 index 0000000..ec145da --- /dev/null +++ b/backend/live/emailService.js @@ -0,0 +1,264 @@ +const nodemailer = require('nodemailer'); +const cron = require('node-cron'); +const axios = require('axios'); + +// Configure transporter +let transporter; + +const createTransporter = async () => { + if (transporter) return transporter; + + // --------------------------------------------------------- + // 1. GMAIL CONFIGURATION (For Real Emails) + // To use Gmail: + // 1. Go to Google Account > Security > 2-Step Verification > Enable it. + // 2. Go to Google Account > Security > App Passwords > Create one for "Mail". + // 3. Paste your email and the 16-character App Password below. + // --------------------------------------------------------- + const GMAIL_USER = "akashachintya54@gmail.com"; // <--- PUT YOUR EMAIL HERE + const GMAIL_APP_PASSWORD = "awfe rpke arpl qzzq"; // <--- PUT YOUR APP PASSWORD HERE (Not your login password) + + if (GMAIL_USER && GMAIL_USER.includes("@") && GMAIL_APP_PASSWORD && GMAIL_APP_PASSWORD.length > 10) { + // Use Gmail + transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: GMAIL_USER, + pass: GMAIL_APP_PASSWORD, + }, + }); + console.log(`📧 Using Gmail: ${GMAIL_USER}`); + } else { + // Use Ethereal (Fallback) + console.log('⚠️ Invalid Gmail credentials. Using Ethereal (Fake Inbox).'); + const testAccount = await nodemailer.createTestAccount(); + transporter = nodemailer.createTransport({ + host: "smtp.ethereal.email", + port: 587, + secure: false, + auth: { + user: testAccount.user, + pass: testAccount.pass, + }, + }); + } + + return transporter; +}; + +// Subscriptions Store (In-Memory for now, can be persisted to JSON) +const subscriptions = new Map(); // email -> [symbols] + +const saveSubscription = (email, symbols) => { + subscriptions.set(email, symbols); + console.log(`Subscribed: ${email} -> ${symbols.join(', ')}`); +}; + +const removeSubscription = (email) => { + subscriptions.delete(email); + console.log(`Unsubscribed: ${email}`); +}; + +// Fetch news for a list of symbols +const fetchNewsForSymbols = async (symbols) => { + if (!symbols || symbols.length === 0) return []; + + // Helper to fetch news for a single symbol using V1 Search API + const fetchV1 = async (symbol) => { + try { + const url = `https://query2.finance.yahoo.com/v1/finance/search?q=${symbol}&newsCount=3`; + const response = await axios.get(url, { + headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } + }); + const news = response.data.news || []; + return news.map(item => ({ + title: item.title, + url: item.link, + publisher: item.publisher, + timestamp: item.providerPublishTime, + summary: item.publisher, + imageUrl: item.thumbnail?.url // Capture image + })); + } catch (e) { + return []; + } + }; + + try { + // Since V2 batch API is unreliable, we fetch V1 news for the first 3 symbols in parallel and combine + const topSymbols = symbols.slice(0, 3); + const promises = topSymbols.map(s => fetchV1(s)); + const results = await Promise.all(promises); + + // Flatten arrays + const allNews = results.flat(); + + // Deduplicate by URL (in case same article appears for multiple stocks) + const uniqueNews = Array.from(new Map(allNews.map(item => [item.url, item])).values()); + + return uniqueNews.slice(0, 5); // Return top 5 unique articles + } catch (error) { + console.error('Error fetching email news:', error.message); + return []; + } +}; + +// Generate HTML Email (Dark Mode with Images) +const generateEmailHtml = (newsItems) => { + const listItems = newsItems.map(item => ` + + + + + + ${item.imageUrl ? ` + + ` : ''} + +
+

+ ${item.title} +

+

+ ${item.publisher} • ${new Date(item.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+

+ ${item.summary || 'Click to read the full story.'} +

+
+ + News Image + +
+ + + `).join(''); + + return ` + + + + + + FinStream Daily Digest + + +
+ + + + +
+ + + + + + + + + + + + + + + + +
+

FinStream

+

Your Daily Market Digest

+
+ + + + + ${listItems} +
+

Top Stories

+
+
+

+ Start your day with the market's pulse. +

+

+ © ${new Date().getFullYear()} FinStream. All rights reserved. +

+
+
+
+ + + `; +}; + +// Send Daily Digest +const sendDailyDigest = async () => { + console.log('Running Daily Digest Job...'); + const mailTransport = await createTransporter(); + const results = []; + + for (const [email, symbols] of subscriptions.entries()) { + const news = await fetchNewsForSymbols(symbols); + if (news.length > 0) { + const html = generateEmailHtml(news); + + const info = await mailTransport.sendMail({ + from: '"FinStream Bot" ', + to: email, + subject: `FinStream Daily Digest: News for ${symbols.join(', ')}`, + html: html, + }); + + console.log("Message sent: %s", info.messageId); + const previewUrl = nodemailer.getTestMessageUrl(info); + console.log("Preview URL: %s", previewUrl); + results.push({ email, previewUrl }); + } + } + return results; +}; + +// Send Single Test Email (Directly) +const sendTestEmail = async (email, symbols) => { + console.log(`Sending Test Email to ${email}...`); + const mailTransport = await createTransporter(); + + // Default symbols if none provided + const targetSymbols = (symbols && symbols.length > 0) ? symbols : ['AAPL', 'TSLA', 'GOOGL']; + const news = await fetchNewsForSymbols(targetSymbols); + + if (news.length === 0) { + throw new Error('No news found for symbols'); + } + + const html = generateEmailHtml(news); + const info = await mailTransport.sendMail({ + from: '"FinStream Bot" ', + to: email, + subject: `[TEST] FinStream Digest for ${targetSymbols.join(', ')}`, + html: html, + }); + + const previewUrl = nodemailer.getTestMessageUrl(info); + console.log("Test Preview URL: %s", previewUrl); + return previewUrl; +}; + +// Initialize Cron Job +const initCron = () => { + // Schedule for 8:00 AM every day + // For demo purposes, we can also trigger it manually via API + cron.schedule('0 8 * * *', () => { + sendDailyDigest(); + }); + console.log('Cron job initialized: Daily Digest at 08:00 AM'); +}; + +module.exports = { + saveSubscription, + removeSubscription, + sendDailyDigest, + sendTestEmail, + initCron +}; diff --git a/backend/live/package-lock.json b/backend/live/package-lock.json index b05cf4f..eb41df2 100644 --- a/backend/live/package-lock.json +++ b/backend/live/package-lock.json @@ -12,6 +12,8 @@ "axios": "^1.13.2", "cors": "^2.8.5", "express": "^4.18.2", + "node-cron": "^4.2.1", + "nodemailer": "^7.0.11", "ws": "^8.13.0" } }, @@ -642,6 +644,24 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/backend/live/package.json b/backend/live/package.json index c78b059..ff6bd45 100644 --- a/backend/live/package.json +++ b/backend/live/package.json @@ -7,6 +7,8 @@ "axios": "^1.13.2", "cors": "^2.8.5", "express": "^4.18.2", + "node-cron": "^4.2.1", + "nodemailer": "^7.0.11", "ws": "^8.13.0" } } diff --git a/backend/live/server.js b/backend/live/server.js index 58846b6..fc24388 100644 --- a/backend/live/server.js +++ b/backend/live/server.js @@ -15,6 +15,111 @@ const server = app.listen(3001, () => { // --- Yahoo Finance Proxy Endpoints --- +// Import Email Service +const emailService = require('./emailService'); +emailService.initCron(); // Start Cron Job + +// 0. Email Subscriptions +app.use(express.json()); // Enable JSON parsing + +app.post("/api/subscribe", (req, res) => { + const { email, symbols } = req.body; + if (!email || !symbols || !Array.isArray(symbols)) { + return res.status(400).json({ error: "Invalid data. Email and symbols array required." }); + } + emailService.saveSubscription(email, symbols); + res.json({ success: true, message: "Subscribed successfully" }); +}); + +app.post("/api/unsubscribe", (req, res) => { + const { email } = req.body; + if (!email) return res.status(400).json({ error: "Email required" }); + emailService.removeSubscription(email); + res.json({ success: true, message: "Unsubscribed successfully" }); +}); + +app.post("/api/trigger-email", async (req, res) => { + try { + const { email, symbols } = req.body; + + let result; + if (email) { + // Direct Test Mode + const previewUrl = await emailService.sendTestEmail(email, symbols); + result = { success: true, message: "Test email sent", previewUrl }; + } else { + // Global Broadcast Mode + const previews = await emailService.sendDailyDigest(); + result = { success: true, message: "Daily digest triggered", previews }; + } + + res.json(result); + } catch (error) { + console.error("Manual trigger failed:", error); + res.status(500).json({ error: "Failed to trigger email: " + error.message }); + } +}); + +// 0.5 News Proxy +app.get("/api/yahoo/news", async (req, res) => { + const symbols = req.query.symbols; + const query = req.query.q; + + try { + let url; + let isSearch = false; + + if (query) { + // Explicit generic search + url = `https://query2.finance.yahoo.com/v1/finance/search?q=${query}&newsCount=20`; + } else { + // "News" request (portfolio or market) + // v2/news is blocked/unreliable, so we fallback to v1/search + // We take the first symbol from the list to search for relevant news + let targetSymbol = 'market'; + if (symbols && symbols !== 'market') { + const parts = symbols.split(','); + targetSymbol = parts[0]; // Search for the first/primary symbol + } else { + targetSymbol = 'economy'; // General market news search term + } + url = `https://query2.finance.yahoo.com/v1/finance/search?q=${targetSymbol}&newsCount=20`; + } + + console.log(`Fetching News from: ${url}`); + + const response = await axios.get(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'application/json' + } + }); + + let articles = []; + const news = response.data.news || []; + + // Map V1 Search Results to our Article format + articles = news.map(item => ({ + id: item.uuid, + title: item.title, + description: item.publisher || 'Click to read more', // Search API lacks summary + source: item.publisher, + timestamp: item.providerPublishTime, + url: item.link, + imageUrl: item.thumbnail?.url, // Fix: Capture thumbnail URL + stockTicker: item.relatedTickers?.[0] || 'MARKET' + })); + + console.log(`Found ${articles.length} articles`); + res.json(articles.slice(0, 20)); + + } catch (error) { + console.error("Yahoo News Error Details:", error.response?.data || error.message); + // Fallback or empty array instead of 500 to prevent UI crash + res.json([]); + } +}); + // 1. Search Stock app.get("/api/yahoo/search", async (req, res) => { const query = req.query.q; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54b2347..7e97480 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,7 +6,6 @@ import HomePage from './pages/HomePage'; import ProfilePage from './pages/Profile/ProfilePage'; import CustomDashboard from './pages/CustomDashboard'; import NewsPage from './pages/News'; -import MarketsPage from './pages/Markets'; import ResearchPage from './pages/Research'; import PersonalFinancePage from './pages/PersonalFinance'; import VideosPage from './pages/Videos'; @@ -56,7 +55,6 @@ function App() { } /> - } /> diff --git a/frontend/src/components/features/News/EmailSubscriptionModal.css b/frontend/src/components/features/News/EmailSubscriptionModal.css new file mode 100644 index 0000000..9e37e13 --- /dev/null +++ b/frontend/src/components/features/News/EmailSubscriptionModal.css @@ -0,0 +1,90 @@ +.subscription-modal { + max-width: 450px; + width: 100%; + background: #1a1a1a; + border-radius: 12px; + border: 1px solid #333; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); + overflow: hidden; +} + +.modal-description { + color: #d1d4dc; + font-size: 1rem; + line-height: 1.5; + margin-bottom: 1.5rem; + text-align: center; +} + +.holdings-preview { + color: #00e6b8; + font-weight: 600; + font-size: 0.9rem; +} + +.subscription-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.email-input { + width: 100%; + padding: 12px 16px; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 8px; + font-size: 1rem; + color: #fff; + transition: all 0.2s; +} + +.email-input:focus { + outline: none; + border-color: #00d4aa; + background: #333; +} + +.subscribe-btn { + width: 100%; + padding: 12px; + font-size: 1rem; + font-weight: 600; + background: #00d4aa; + color: #000; + border: none; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.subscribe-btn:hover:not(:disabled) { + background: #00bfa5; + transform: translateY(-1px); +} + +.subscribe-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.error-message { + color: #ff6b6b; + font-size: 0.9rem; + text-align: center; + margin: 0; +} + +.success-message { + color: #00d4aa; + font-size: 0.9rem; + text-align: center; + margin: 0; +} + +.disclaimer { + margin-top: 1.5rem; + font-size: 0.75rem; + color: #666; + text-align: center; +} \ No newline at end of file diff --git a/frontend/src/components/features/News/EmailSubscriptionModal.tsx b/frontend/src/components/features/News/EmailSubscriptionModal.tsx new file mode 100644 index 0000000..54b5a88 --- /dev/null +++ b/frontend/src/components/features/News/EmailSubscriptionModal.tsx @@ -0,0 +1,123 @@ + +import React, { useState } from 'react'; +import axios from 'axios'; +import { usePortfolio } from '../../../context/PortfolioContext'; +import { newsService } from '../../../services/newsService'; +import Button from '../../ui/Button'; +import './EmailSubscriptionModal.css'; + +interface EmailSubscriptionModalProps { + onClose: () => void; +} + +const EmailSubscriptionModal: React.FC = ({ onClose }) => { + const { holdings } = usePortfolio(); + const [email, setEmail] = useState(''); + const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [message, setMessage] = useState(''); + + const symbols = holdings.map(h => h.symbol); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (symbols.length === 0) { + setStatus('error'); + setMessage("Your portfolio is empty. Add some stocks first!"); + return; + } + + try { + setStatus('loading'); + await newsService.subscribe(email, symbols); + setStatus('success'); + setMessage("Successfully subscribed! Check your inbox tomorrow morning."); + setTimeout(onClose, 2000); + } catch (error) { + setStatus('error'); + setMessage("Failed to subscribe. Please try again."); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

Get Daily News Alerts

+ +
+ +
+

+ Receive a daily morning digest of breaking news for your {symbols.length} portfolio holdings: +
+ {symbols.slice(0, 5).join(', ')}{symbols.length > 5 ? '...' : ''} +

+ +
+
+ setEmail(e.target.value)} + required + className="email-input" + /> +
+ + {status === 'error' &&

{message}

} + {status === 'success' &&

{message}

} + + +
+ +
+ +
+ +

+ We'll only send one email per day at 8:00 AM. Unsubscribe anytime. +

+
+
+
+ ); +}; + +export default EmailSubscriptionModal; diff --git a/frontend/src/components/layout/SecondaryNav/SecondaryNav.tsx b/frontend/src/components/layout/SecondaryNav/SecondaryNav.tsx index b461812..b89ec52 100644 --- a/frontend/src/components/layout/SecondaryNav/SecondaryNav.tsx +++ b/frontend/src/components/layout/SecondaryNav/SecondaryNav.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router-dom'; +import { Link, NavLink } from 'react-router-dom'; import React, { useState, useEffect, useCallback } from 'react'; import Button from '../../ui/Button'; import { useKeycloak } from '@react-keycloak/web'; @@ -52,8 +52,9 @@ const SecondaryNav: React.FC = () => { { id: 'my-profile', label: 'Profile', href: '/profile' }, { id: 'my-portfolio', label: 'Portfolio', href: '/portfolio' }, { id: 'dashboard', label: 'Custom Dashboard', href: '/dashboard' }, - { id: 'markets', label: 'Markets', href: '/markets' }, - { id: 'research', label: 'Live Market Data', href: '/research' }, + { id: 'news', label: 'News', href: '/news' }, + + { id: 'research', label: 'Live Market Data', href: '/live-markets' }, // Corrected href from /research if needed, keeping context { id: 'historical-data', label: 'Historical Data', href: '/historical-data' }, ]; @@ -103,9 +104,14 @@ const SecondaryNav: React.FC = () => { return true; }).map((item) => (
  • - + + isActive ? 'secondary-nav-link active' : 'secondary-nav-link' + } + > {item.label} - +
  • ))} diff --git a/frontend/src/components/ui/ArticleCard/ArticleCard.css b/frontend/src/components/ui/ArticleCard/ArticleCard.css index f7e3130..f9f7635 100644 --- a/frontend/src/components/ui/ArticleCard/ArticleCard.css +++ b/frontend/src/components/ui/ArticleCard/ArticleCard.css @@ -144,4 +144,4 @@ .article-timestamp { color: #888; font-size: 11px; -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/ArticleCard/ArticleCard.tsx b/frontend/src/components/ui/ArticleCard/ArticleCard.tsx index 7e58e61..b0256f7 100644 --- a/frontend/src/components/ui/ArticleCard/ArticleCard.tsx +++ b/frontend/src/components/ui/ArticleCard/ArticleCard.tsx @@ -3,74 +3,74 @@ import { ArticleCardProps } from '../../../types'; import StockTicker from '../StockTicker'; import './ArticleCard.css'; -const ArticleCard: React.FC = -({ - article, - variant = 'standard', - onClick -}) => { - const { - title, - description, - urlToImage: imageUrl, - stockTicker, - stockChange, - stockChangePercent, - } = (article as any) || {}; +const ArticleCard: React.FC = + ({ + article, + variant = 'standard', + onClick + }) => { + const { + title, + description, + urlToImage: imageUrl, + stockTicker, + stockChange, + stockChangePercent, + } = (article as any) || {}; - const handleClick = () => { - if (onClick) { - onClick(); - } - }; + const handleClick = () => { + if (onClick) { + onClick(); + } + }; - const renderStockTicker = () => { - if (stockTicker && stockChange !== undefined && stockChangePercent !== undefined) { - return ( - - ); - } - return null; - }; - - if (!article) return null; // Safety check + const renderStockTicker = () => { + if (stockTicker && stockChange !== undefined && stockChangePercent !== undefined) { + return ( + + ); + } + return null; + }; - return ( -
    -
    -

    {title}

    - - {/* 4. FIX: Image Block - Only render if imageUrl exists AND the variant is "featured" */} - {imageUrl && variant === 'featured' && ( -
    - {title -
    - )} - - {description && variant !== 'compact' && ( -

    {description}

    - )} -
    -
    - {(article as any)?.source?.name} + if (!article) return null; // Safety check + + return ( +
    +
    +

    {title}

    + + {/* 4. FIX: Image Block - Only render if imageUrl exists AND the variant is "featured" */} + {imageUrl && variant === 'featured' && ( +
    + {title +
    + )} + + {description && variant !== 'compact' && ( +

    {description}

    + )} +
    +
    + {(article as any)?.source?.name} +
    + {renderStockTicker()}
    - {renderStockTicker()}
    -
    -
    - ); -}; + + ); + }; export default ArticleCard; \ No newline at end of file diff --git a/frontend/src/components/ui/ChartModal/ChartModal.tsx b/frontend/src/components/ui/ChartModal/ChartModal.tsx index e166747..3772162 100644 --- a/frontend/src/components/ui/ChartModal/ChartModal.tsx +++ b/frontend/src/components/ui/ChartModal/ChartModal.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useRef } from 'react'; import { createChart, ColorType, IChartApi, CandlestickSeries, HistogramSeries, LineSeries, Time } from 'lightweight-charts'; import { stockDataService } from '../../../services/stockDataService'; +import { yahooFinanceService } from '../../../services/yahooFinance'; import './ChartModal.css'; interface ChartModalProps { @@ -37,7 +38,10 @@ export const ChartModal: React.FC = ({ symbol, onClose }) => { const fetchData = async () => { try { setLoading(true); - const data = await stockDataService.getEODData(symbol); + const [data, quote] = await Promise.all([ + stockDataService.getEODData(symbol), + yahooFinanceService.getStockQuote(symbol) + ]); if (!data || !data.data || data.data.length === 0) { throw new Error('No data available'); @@ -55,18 +59,30 @@ export const ChartModal: React.FC = ({ symbol, onClose }) => { })).sort((a: any, b: any) => new Date(a.time).getTime() - new Date(b.time).getTime()); // Stats calculation - const latest = candles[candles.length - 1]; - const previous = candles[candles.length - 2]; - const change = latest.close - previous.close; - const changePercent = (change / previous.close) * 100; + let currentPrice = 0; + let change = 0; + let changePercent = 0; + + if (quote) { + currentPrice = quote.price; + change = quote.price - quote.previousClose; + changePercent = (change / quote.previousClose) * 100; + } else { + // Fallback to last candle if quote fails + const latest = candles[candles.length - 1]; + const previous = candles[candles.length - 2]; + currentPrice = latest.close; + change = latest.close - previous.close; + changePercent = (change / previous.close) * 100; + } setStats({ - current: latest.close, + current: currentPrice, change: change, changePercent: changePercent, high: Math.max(...candles.map((c: any) => c.high)), low: Math.min(...candles.map((c: any) => c.low)), - volume: latest.volume + volume: candles[candles.length - 1].volume }); if (chartContainerRef.current) { diff --git a/frontend/src/components/ui/LiveChart/LiveChart.css b/frontend/src/components/ui/LiveChart/LiveChart.css index a8db6f7..4c77089 100644 --- a/frontend/src/components/ui/LiveChart/LiveChart.css +++ b/frontend/src/components/ui/LiveChart/LiveChart.css @@ -6,7 +6,6 @@ /* More subtle border */ padding: 20px 24px 20px; width: 100%; - transition: 0.25s ease; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); } diff --git a/frontend/src/components/ui/LiveChart/LiveChart.tsx b/frontend/src/components/ui/LiveChart/LiveChart.tsx index 7b4b354..7858e7d 100644 --- a/frontend/src/components/ui/LiveChart/LiveChart.tsx +++ b/frontend/src/components/ui/LiveChart/LiveChart.tsx @@ -47,8 +47,41 @@ const LiveChart: React.FC = ({ symbol, onRemove }) => { const [data, setData] = useState([]); const [stats, setStats] = useState({ high: 0, low: Infinity, vol: 0 }); - // Connect to WebSocket + // Connect to WebSocket & Fetch History useEffect(() => { + let isMounted = true; + + // 1. Fetch Historical Data first (so we have something to show immediately) + const fetchHistory = async () => { + try { + // Fetch last 1 day of 5-minute intervals + const response = await fetch(`http://localhost:3001/api/yahoo/history?symbol=${symbol}&range=1d&interval=5m`); + const history = await response.json(); + + if (isMounted && Array.isArray(history) && history.length > 0) { + const formattedHistory = history.map((h: any) => ({ + x: h.timestamp * 1000, + y: h.close, + v: h.volume + })); + setData(formattedHistory); + + // Initialize stats from history + const prices = formattedHistory.map((d: any) => d.y); + setStats({ + high: Math.max(...prices), + low: Math.min(...prices), + vol: formattedHistory.reduce((acc: number, curr: any) => acc + curr.v, 0) + }); + } + } catch (err) { + console.error("Failed to load history for", symbol, err); + } + }; + + fetchHistory(); + + // 2. Subscribe to Live Updates const ws = connectLiveFinnhub(symbol, (trade) => { if (trade.s === symbol) { const price = trade.p; @@ -56,14 +89,15 @@ const LiveChart: React.FC = ({ symbol, onRemove }) => { const time = trade.t; setData((prev) => { - // Filter out any potential duplicates or invalid timestamps first if needed - const newData = [...prev, { x: time, y: price, v: vol }]; + // Avoid duplicates if WS sends old data overlapping with history + const filteredPrev = prev.filter(d => d.x < time); + const newData = [...filteredPrev, { x: time, y: price, v: vol }]; - // Sort by timestamp to ensure line draws left-to-right correctly + // Sort by timestamp newData.sort((a, b) => a.x - b.x); - // Keep last 100 points - return newData.slice(-100); + // Keep reasonable history length (e.g. 500 points) to avoid memory leaks + return newData.slice(-500); }); setStats((prev) => ({ @@ -74,7 +108,10 @@ const LiveChart: React.FC = ({ symbol, onRemove }) => { } }); - return () => ws.close(); + return () => { + isMounted = false; + ws.close(); + }; }, [symbol]); // Chart Configuration @@ -256,4 +293,4 @@ const LiveChart: React.FC = ({ symbol, onRemove }) => { ); }; -export default LiveChart; +export default React.memo(LiveChart); diff --git a/frontend/src/components/ui/SearchBar/SearchBar.tsx b/frontend/src/components/ui/SearchBar/SearchBar.tsx index 7cce3cd..9048225 100644 --- a/frontend/src/components/ui/SearchBar/SearchBar.tsx +++ b/frontend/src/components/ui/SearchBar/SearchBar.tsx @@ -2,16 +2,17 @@ import React, { useState } from 'react'; import { SearchBarProps } from '../../../types'; import './SearchBar.css'; -const SearchBar: React.FC = ({ - placeholder, - onSearch, - className = '' +const SearchBar: React.FC = ({ + placeholder, + onSearch, + className = '' }) => { const [query, setQuery] = useState(''); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSearch(query); + setQuery(''); // Clear input after search }; const handleInputChange = (e: React.ChangeEvent) => { @@ -29,16 +30,16 @@ const SearchBar: React.FC = ({ className="search-input" /> diff --git a/frontend/src/components/ui/StockSearch/StockSearch.css b/frontend/src/components/ui/StockSearch/StockSearch.css new file mode 100644 index 0000000..17d58d5 --- /dev/null +++ b/frontend/src/components/ui/StockSearch/StockSearch.css @@ -0,0 +1,157 @@ +.stock-search-container { + position: relative; + width: 100%; +} + +.search-dropdown { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: #1a1a1a; + border: 1px solid #444; + border-top: none; + border-radius: 0 0 12px 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + z-index: 1000; + max-height: 400px; + overflow: hidden; + margin-top: 4px; +} + +.search-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 1.5rem; + color: #a0a0a0; + font-size: 0.9rem; +} + +.loading-spinner { + width: 20px; + height: 20px; + border: 2px solid #333; + border-top: 2px solid #00d4aa; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.search-results { + max-height: 400px; + overflow-y: auto; +} + +.results-header { + padding: 0.75rem 1rem; + border-bottom: 1px solid #333; + background: #1a1a1a; + position: sticky; + top: 0; + z-index: 10; +} + +.results-count { + color: #a0a0a0; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.results-list { + display: flex; + flex-direction: column; +} + +.search-result-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: transparent; + border-bottom: 1px solid #2a2a2a; + transition: all 0.2s ease; + cursor: pointer; +} + +.search-result-item:last-child { + border-bottom: none; +} + +.search-result-item:hover { + background: #2a2a2a; + border-left: 3px solid #00d4aa; + padding-left: calc(1rem - 3px); +} + +.result-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.result-header { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.result-symbol { + font-weight: 700; + color: #00d4aa; + font-size: 0.95rem; + background: rgba(0, 212, 170, 0.1); + padding: 2px 6px; + border-radius: 4px; +} + +.result-name { + color: #e0e0e0; + font-size: 0.9rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.result-details { + display: flex; + gap: 0.75rem; + font-size: 0.75rem; + color: #888; +} + +.add-button { + min-width: 60px; + font-size: 0.8rem; + padding: 0.4rem 0.8rem; +} + +.no-results { + text-align: center; + color: #a0a0a0; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.no-results-icon { + font-size: 1.5rem; + opacity: 0.6; +} \ No newline at end of file diff --git a/frontend/src/components/ui/StockSearch/StockSearch.tsx b/frontend/src/components/ui/StockSearch/StockSearch.tsx new file mode 100644 index 0000000..6a93d21 --- /dev/null +++ b/frontend/src/components/ui/StockSearch/StockSearch.tsx @@ -0,0 +1,127 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { yahooService } from '../../../services/yahooService'; +import { Stock } from '../../../types'; +import SearchBar from '../SearchBar'; +import Button from '../Button'; +import './StockSearch.css'; + +interface StockSearchProps { + onSelect: (stock: Stock) => void; + placeholder?: string; +} + +const StockSearch: React.FC = ({ onSelect, placeholder = "Search for stocks..." }) => { + const [searchQuery, setSearchQuery] = useState(''); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const searchContainerRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) { + setSearchQuery(''); + setSearchResults([]); + } + }; + + if (searchQuery) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [searchQuery]); + + const handleSearch = async (query: string) => { + setSearchQuery(query); + if (query.trim().length > 0) { + setIsSearching(true); + + try { + // Use debouncing in real app, but for now direct call + const searchResults = await yahooService.searchStocks(query); + setSearchResults(searchResults); + } catch (err) { + console.error('Error searching stocks:', err); + setSearchResults([]); + } finally { + setIsSearching(false); + } + } else { + setSearchResults([]); + } + }; + + const handleSelectStock = (stock: Stock) => { + onSelect(stock); + setSearchQuery(''); + setSearchResults([]); + }; + + return ( +
    + + + {searchQuery && ( +
    + {isSearching ? ( +
    +
    + Searching... +
    + ) : searchResults.length > 0 ? ( +
    +
    + {searchResults.length} results found +
    +
    + {searchResults.map((stock) => ( +
    handleSelectStock(stock)} + > +
    +
    + {stock.symbol} + {stock.name} +
    +
    + {stock.exch} + {stock.type} +
    +
    + +
    + ))} +
    +
    + ) : ( +
    +
    🔍
    + No stocks found matching "{searchQuery}" +
    + )} +
    + )} +
    + ); +}; + +export default StockSearch; diff --git a/frontend/src/pages/CustomDashboard/CustomDashboard.css b/frontend/src/pages/CustomDashboard/CustomDashboard.css index f922eec..574ceea 100644 --- a/frontend/src/pages/CustomDashboard/CustomDashboard.css +++ b/frontend/src/pages/CustomDashboard/CustomDashboard.css @@ -295,6 +295,24 @@ font-style: italic; } +/* Section Headers */ +.section-title { + font-size: 1.5rem; + font-weight: 600; + color: #fff; + margin: 0; + display: flex; + align-items: baseline; + gap: 12px; +} + +.drag-hint { + font-size: 0.85rem; + font-weight: 400; + color: #888; + font-style: italic; +} + /* Saved Stocks Section */ .saved-stocks-section { background: #1a1a1a; @@ -317,6 +335,36 @@ gap: 1rem; } +.saved-stocks-container.columns { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; +} + +/* Columns specific adjustments to prevent overflow */ +.saved-stocks-container.columns .stock-item-row { + flex-wrap: wrap; + gap: 0.5rem; +} + +.saved-stocks-container.columns .stock-info { + min-width: 180px; +} + +.saved-stocks-container.columns .stock-actions { + flex-wrap: wrap; + gap: 0.25rem; +} + +.saved-stocks-container.columns .saved-stock-item button { + padding: 4px 8px; + font-size: 0.75rem; +} + +.saved-stocks-container.columns .saved-stock-item { + overflow-x: hidden; +} + .saved-stocks-container.dragging-active { background: rgba(0, 212, 170, 0.05); border-radius: 8px; @@ -637,7 +685,8 @@ justify-content: center; } - .saved-stocks-container.grid { + .saved-stocks-container.grid, + .saved-stocks-container.columns { grid-template-columns: 1fr; } diff --git a/frontend/src/pages/CustomDashboard/CustomDashboard.tsx b/frontend/src/pages/CustomDashboard/CustomDashboard.tsx index 821dbb8..65a4380 100644 --- a/frontend/src/pages/CustomDashboard/CustomDashboard.tsx +++ b/frontend/src/pages/CustomDashboard/CustomDashboard.tsx @@ -178,7 +178,7 @@ const CustomDashboard: React.FC = () => { const [isLoadingIndices, setIsLoadingIndices] = useState(true); const [error, setError] = useState(null); const [isRealtimeConnected, setIsRealtimeConnected] = useState(false); - const [viewMode, setViewMode] = useState<'list' | 'grid'>('list'); + const [viewMode, setViewMode] = useState<'list' | 'columns' | 'grid'>('list'); const [isDragging, setIsDragging] = useState(false); const [isMarketOpen, setIsMarketOpen] = useState(null); const [marketStatus, setMarketStatus] = useState(''); @@ -574,7 +574,7 @@ const CustomDashboard: React.FC = () => { return (
    -

    Portfolio Analytics Dashboard

    +

    Analytics Dashboard

    {/* Search Section Moved Here */}
    @@ -657,7 +657,7 @@ const CustomDashboard: React.FC = () => {

    - Saved Stocks ({savedStocks.length}) + Saved Stocks Drag to rearrange

    @@ -669,6 +669,14 @@ const CustomDashboard: React.FC = () => { > List + + + +
    + +
    + +
    +
    + +
    + {symbols.map(symbol => ( + + ))}
    ); diff --git a/frontend/src/pages/Markets/MarketsPage.css b/frontend/src/pages/Markets/MarketsPage.css deleted file mode 100644 index cb33de6..0000000 --- a/frontend/src/pages/Markets/MarketsPage.css +++ /dev/null @@ -1,300 +0,0 @@ -.markets-page { - min-height: 100vh; - background: #000; - color: #fff; - padding: 20px; -} - -.markets-header { - text-align: center; - margin-bottom: 40px; - padding: 40px 0; -} - -.markets-title { - font-size: 3rem; - font-weight: 700; - margin: 0 0 16px 0; - background: linear-gradient(135deg, #00d4aa, #0099cc); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.markets-subtitle { - font-size: 1.2rem; - color: #a0a0a0; - margin: 0; - max-width: 600px; - margin: 0 auto; -} - -.markets-controls { - width: 100%; - margin: 0 auto 40px; - display: flex; - flex-direction: column; - gap: 24px; -} - -.search-section { - display: flex; - justify-content: center; -} - -.markets-search { - max-width: 500px; - width: 100%; -} - -.tabs-container { - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 12px; -} - -.tab-button { - transition: all 0.2s ease; -} - -.tab-button:hover { - transform: translateY(-2px); -} - -.markets-content { - width: 100%; - margin: 0 auto; -} - -.loading-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 80px 20px; - text-align: center; -} - -.loading-spinner { - width: 40px; - height: 40px; - border: 3px solid #333; - border-top: 3px solid #00d4aa; - border-radius: 50%; - animation: spin 1s linear infinite; - margin-bottom: 16px; -} - -@keyframes spin { - 0% { - transform: rotate(0deg); - } - - 100% { - transform: rotate(360deg); - } -} - -.search-results { - margin-bottom: 40px; -} - -.search-results h2 { - font-size: 1.5rem; - margin-bottom: 24px; - color: #00d4aa; -} - -.market-section { - margin-bottom: 40px; -} - -.section-title { - font-size: 1.8rem; - margin-bottom: 24px; - color: #fff; - text-align: center; -} - -.market-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 20px; -} - -.market-card { - background: #1a1a1a; - border: 1px solid #333; - border-radius: 12px; - padding: 20px; - transition: all 0.3s ease; - position: relative; - overflow: hidden; -} - -.market-card:hover { - transform: translateY(-4px); - border-color: #00d4aa; - box-shadow: 0 8px 25px rgba(0, 212, 170, 0.15); -} - -.market-card-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 16px; -} - -.stock-info { - flex: 1; -} - -.stock-symbol { - font-size: 1.4rem; - font-weight: 700; - margin: 0 0 4px 0; - color: #fff; -} - -.stock-name { - font-size: 0.9rem; - color: #a0a0a0; - margin: 0; - line-height: 1.3; -} - -.price-info { - text-align: right; -} - -.stock-price { - font-size: 1.5rem; - font-weight: 700; - color: #fff; - display: block; - margin-bottom: 4px; -} - -.price-change { - font-size: 0.9rem; - font-weight: 600; - display: flex; - flex-direction: column; - align-items: flex-end; -} - -.price-change.positive { - color: #00d4aa; -} - -.price-change.negative { - color: #ff4757; -} - -.sparkline-container { - height: 60px; - margin: 16px 0; - background: rgba(255, 255, 255, 0.05); - border-radius: 8px; - padding: 8px; -} - -.market-card-footer { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 0.8rem; - color: #a0a0a0; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid #333; -} - -.volume-info, -.market-cap { - display: flex; - align-items: center; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .markets-page { - padding: 16px; - } - - .markets-title { - font-size: 2rem; - } - - .markets-subtitle { - font-size: 1rem; - } - - .markets-controls { - margin-bottom: 32px; - } - - .tabs-container { - gap: 8px; - } - - .market-grid { - grid-template-columns: 1fr; - gap: 16px; - } - - .market-card { - padding: 16px; - } - - .market-card-header { - flex-direction: column; - gap: 12px; - } - - .price-info { - text-align: left; - width: 100%; - } - - .price-change { - flex-direction: row; - align-items: center; - gap: 8px; - } - - .market-card-footer { - flex-direction: column; - gap: 8px; - align-items: flex-start; - } -} - -@media (max-width: 480px) { - .markets-header { - padding: 20px 0; - margin-bottom: 24px; - } - - .markets-title { - font-size: 1.8rem; - } - - .tabs-container { - flex-direction: column; - align-items: center; - } - - .tab-button { - width: 200px; - } - - .stock-symbol { - font-size: 1.2rem; - } - - .stock-price { - font-size: 1.3rem; - } -} \ No newline at end of file diff --git a/frontend/src/pages/Markets/MarketsPage.tsx b/frontend/src/pages/Markets/MarketsPage.tsx deleted file mode 100644 index 9bed4c6..0000000 --- a/frontend/src/pages/Markets/MarketsPage.tsx +++ /dev/null @@ -1,351 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { polygonService } from '../../services/polygonService'; -import MarketIndex from '../../components/ui/MarketIndex'; -import StockTicker from '../../components/ui/StockTicker'; -import SparklineChart from '../../components/ui/SparklineChart'; -import SearchBar from '../../components/ui/SearchBar'; -import Button from '../../components/ui/Button'; -import './MarketsPage.css'; - - - -interface MarketData { - symbol: string; - name: string; - price: number; - change: number; - changePercent: number; - volume: number; - marketCap?: number; - high52Week?: number; - low52Week?: number; - sparklineData?: number[]; -} - -interface MarketIndexData { - symbol: string; - name: string; - price: number; - change: number; - changePercent: number; - volume: number; -} - -const MarketsPage: React.FC = () => { - const [marketIndices, setMarketIndices] = useState([]); - const [topGainers, setTopGainers] = useState([]); - const [topLosers, setTopLosers] = useState([]); - const [mostActive, setMostActive] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [searchQuery, setSearchQuery] = useState(''); - const [searchResults, setSearchResults] = useState([]); - const [isSearching, setIsSearching] = useState(false); - const [selectedTab, setSelectedTab] = useState('indices'); - - const tabs = [ - { id: 'indices', label: 'Market Indices' }, - { id: 'gainers', label: 'Top Gainers' }, - { id: 'losers', label: 'Top Losers' }, - { id: 'active', label: 'Most Active' } - ]; - - useEffect(() => { - loadMarketData(); - }, []); - - const loadMarketData = async () => { - try { - setIsLoading(true); - - // Mock market indices data - const mockIndices: MarketIndexData[] = [ - { - symbol: 'SPX', - name: 'S&P 500', - price: 4567.89, - change: 23.45, - changePercent: 0.52, - volume: 2500000000 - }, - { - symbol: 'DJI', - name: 'Dow Jones', - price: 34567.89, - change: -123.45, - changePercent: -0.36, - volume: 1800000000 - }, - { - symbol: 'IXIC', - name: 'NASDAQ', - price: 14567.89, - change: 89.12, - changePercent: 0.62, - volume: 3200000000 - }, - { - symbol: 'RUT', - name: 'Russell 2000', - price: 1987.65, - change: 12.34, - changePercent: 0.62, - volume: 450000000 - } - ]; - - // Mock top gainers data - const mockGainers: MarketData[] = [ - { - symbol: 'NVDA', - name: 'NVIDIA Corporation', - price: 485.67, - change: 45.23, - changePercent: 10.28, - volume: 45000000, - marketCap: 1200000000000, - high52Week: 502.30, - low52Week: 180.50, - sparklineData: [440, 445, 450, 460, 470, 475, 480, 485] - }, - { - symbol: 'AMD', - name: 'Advanced Micro Devices', - price: 145.89, - change: 12.45, - changePercent: 9.33, - volume: 32000000, - marketCap: 235000000000, - high52Week: 164.46, - low52Week: 78.20, - sparklineData: [133, 135, 138, 140, 142, 144, 145, 146] - }, - { - symbol: 'TSLA', - name: 'Tesla, Inc.', - price: 267.45, - change: 18.90, - changePercent: 7.60, - volume: 85000000, - marketCap: 850000000000, - high52Week: 299.29, - low52Week: 138.80, - sparklineData: [248, 250, 255, 260, 262, 265, 266, 267] - } - ]; - - // Mock top losers data - const mockLosers: MarketData[] = [ - { - symbol: 'META', - name: 'Meta Platforms, Inc.', - price: 312.45, - change: -25.67, - changePercent: -7.59, - volume: 28000000, - marketCap: 790000000000, - high52Week: 384.33, - low52Week: 88.09, - sparklineData: [338, 335, 330, 325, 320, 315, 313, 312] - }, - { - symbol: 'NFLX', - name: 'Netflix, Inc.', - price: 445.67, - change: -28.90, - changePercent: -6.09, - volume: 15000000, - marketCap: 195000000000, - high52Week: 485.00, - low52Week: 285.00, - sparklineData: [474, 470, 465, 460, 455, 450, 447, 446] - } - ]; - - // Mock most active data - const mockActive: MarketData[] = [ - { - symbol: 'AAPL', - name: 'Apple Inc.', - price: 189.45, - change: 2.34, - changePercent: 1.25, - volume: 65000000, - marketCap: 2950000000000, - high52Week: 198.23, - low52Week: 124.17, - sparklineData: [187, 188, 189, 190, 191, 190, 189, 189] - }, - { - symbol: 'MSFT', - name: 'Microsoft Corporation', - price: 378.90, - change: -1.23, - changePercent: -0.32, - volume: 42000000, - marketCap: 2810000000000, - high52Week: 384.30, - low52Week: 309.45, - sparklineData: [380, 379, 378, 377, 378, 379, 378, 379] - } - ]; - - setMarketIndices(mockIndices); - setTopGainers(mockGainers); - setTopLosers(mockLosers); - setMostActive(mockActive); - - } catch (error) { - console.error('Error loading market data:', error); - } finally { - setIsLoading(false); - } - }; - - const handleSearch = async (query: string) => { - setSearchQuery(query); - if (query.trim()) { - setIsSearching(true); - try { - // Mock search results - const mockResults: MarketData[] = [ - { - symbol: query.toUpperCase(), - name: `${query.toUpperCase()} Corporation`, - price: Math.random() * 500 + 50, - change: (Math.random() - 0.5) * 20, - changePercent: (Math.random() - 0.5) * 10, - volume: Math.floor(Math.random() * 10000000), - marketCap: Math.floor(Math.random() * 1000000000000), - sparklineData: Array.from({ length: 8 }, () => Math.random() * 100 + 200) - } - ]; - setSearchResults(mockResults); - } catch (error) { - console.error('Error searching stocks:', error); - setSearchResults([]); - } finally { - setIsSearching(false); - } - } else { - setSearchResults([]); - } - }; - - const getCurrentData = () => { - switch (selectedTab) { - case 'indices': - return marketIndices; - case 'gainers': - return topGainers; - case 'losers': - return topLosers; - case 'active': - return mostActive; - default: - return []; - } - }; - - const renderMarketCard = (item: MarketData | MarketIndexData, index: number) => ( -
    -
    -
    -

    {item.symbol}

    -

    {item.name}

    -
    -
    - ${item.price.toFixed(2)} -
    = 0 ? 'positive' : 'negative'}`}> - {item.change >= 0 ? '+' : ''}{item.change.toFixed(2)} - ({item.change >= 0 ? '+' : ''}{item.changePercent.toFixed(2)}%) -
    -
    -
    - - {('sparklineData' in item && item.sparklineData) && ( -
    - -
    - )} - -
    -
    - Volume: {item.volume.toLocaleString()} -
    - {('marketCap' in item && item.marketCap) && ( -
    - Market Cap: ${(item.marketCap / 1000000000).toFixed(1)}B -
    - )} -
    -
    - ); - - return ( -
    -
    -

    Markets

    -

    Real-time market data and analysis

    -
    - -
    -
    - -
    - -
    - {tabs.map(tab => ( - - ))} -
    -
    - -
    - {isLoading ? ( -
    -
    -

    Loading market data...

    -
    - ) : ( - <> - {searchQuery && ( -
    -

    Search Results for "{searchQuery}"

    -
    - {isSearching ? ( -
    - ) : ( - searchResults.map((result, index) => renderMarketCard(result, index)) - )} -
    -
    - )} - -
    -

    - {tabs.find(tab => tab.id === selectedTab)?.label} -

    -
    - {getCurrentData().map((item, index) => renderMarketCard(item, index))} -
    -
    - - )} -
    -
    - ); -}; - -export default MarketsPage; diff --git a/frontend/src/pages/Markets/index.ts b/frontend/src/pages/Markets/index.ts deleted file mode 100644 index 8be32a9..0000000 --- a/frontend/src/pages/Markets/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './MarketsPage'; diff --git a/frontend/src/pages/News/NewsPage.css b/frontend/src/pages/News/NewsPage.css index 8668f39..2c3a8d1 100644 --- a/frontend/src/pages/News/NewsPage.css +++ b/frontend/src/pages/News/NewsPage.css @@ -5,38 +5,59 @@ padding: 20px; } +.news-header-container { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding: 10px 0; + border-bottom: 1px solid #333; +} + .news-header { - text-align: center; - margin-bottom: 40px; - padding: 40px 0; + text-align: left; + margin-bottom: 0; + padding: 0; } .news-title { - font-size: 3rem; + font-size: 1.8rem; font-weight: 700; - margin: 0 0 16px 0; - background: linear-gradient(135deg, #00d4aa, #0099cc); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; + margin: 0; + color: #00d4aa; } .news-subtitle { - font-size: 1.2rem; + font-size: 1rem; color: #a0a0a0; margin: 0; - max-width: 600px; - margin: 0 auto; +} + +.news-actions { + display: flex; + align-items: center; +} + +.subscribe-button-header { + font-weight: 600; + box-shadow: 0 4px 12px rgba(0, 212, 170, 0.3); } .news-controls { width: 100%; - margin: 0 auto 40px; + margin: 0 auto 20px; display: flex; flex-direction: column; gap: 24px; } +.category-tabs { + display: flex; + justify-content: center; + gap: 16px; + margin-bottom: 16px; +} + .search-section { display: flex; justify-content: center; @@ -48,18 +69,8 @@ } .category-filters { - display: flex; - justify-content: center; - flex-wrap: wrap; - gap: 12px; -} - -.category-button { - transition: all 0.2s ease; -} - -.category-button:hover { - transform: translateY(-2px); + display: none; + /* Hide old filters if not used, or repurpose */ } .news-content { diff --git a/frontend/src/pages/News/NewsPage.tsx b/frontend/src/pages/News/NewsPage.tsx index a7dfc74..a91c98d 100644 --- a/frontend/src/pages/News/NewsPage.tsx +++ b/frontend/src/pages/News/NewsPage.tsx @@ -1,118 +1,55 @@ import React, { useState, useEffect } from 'react'; -import { polygonService } from '../../services/polygonService'; import ArticleCard from '../../components/ui/ArticleCard'; import SearchBar from '../../components/ui/SearchBar'; import Button from '../../components/ui/Button'; +import { newsService, NewsArticle } from '../../services/newsService'; +import { usePortfolio } from '../../context/PortfolioContext'; +import EmailSubscriptionModal from '../../components/features/News/EmailSubscriptionModal'; import './NewsPage.css'; -interface NewsArticle { - id: string; - title: string; - description: string; - source: string; - timestamp: string; - imageUrl?: string; - stockTicker?: string; - stockChange?: number; - stockChangePercent?: number; - isPremium?: boolean; -} - const NewsPage: React.FC = () => { const [articles, setArticles] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [filteredArticles, setFilteredArticles] = useState([]); - const [selectedCategory, setSelectedCategory] = useState('all'); + const [activeTab, setActiveTab] = useState<'market' | 'portfolio'>('market'); + const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false); - const categories = [ - { id: 'all', label: 'All News' }, - { id: 'market', label: 'Market News' }, - { id: 'earnings', label: 'Earnings' }, - { id: 'ipo', label: 'IPO' }, - { id: 'mergers', label: 'M&A' } - ]; + const { holdings } = usePortfolio(); useEffect(() => { loadNews(); - }, []); + }, [activeTab]); // Reload when tab changes - useEffect(() => { - filterArticles(); - }, [articles, searchQuery, selectedCategory]); + const handleSearch = (query: string) => { + setSearchQuery(query); + // If query is empty, reload default news. If has value, search. + loadNews(query); + }; - const loadNews = async () => { + // Modify loadNews to accept optional query override + const loadNews = async (queryOverride?: string) => { try { setIsLoading(true); - - // Mock news data with some real stock tickers - const mockNews: NewsArticle[] = [ - { - id: '1', - title: 'Apple Reports Strong Q4 Earnings, Stock Surges 5%', - description: 'Apple Inc. reported better-than-expected quarterly earnings, driven by strong iPhone sales and services revenue growth.', - source: 'Reuters', - timestamp: '2h ago', - imageUrl: '/api/placeholder/400/250', - stockTicker: 'AAPL', - stockChange: 8.45, - stockChangePercent: 4.8 - }, - { - id: '2', - title: 'Tesla Announces New Gigafactory in Texas', - description: 'Tesla plans to build a new manufacturing facility in Austin, Texas, creating thousands of jobs.', - source: 'Bloomberg', - timestamp: '4h ago', - imageUrl: '/api/placeholder/400/250', - stockTicker: 'TSLA', - stockChange: 12.30, - stockChangePercent: 2.1 - }, - { - id: '3', - title: 'Microsoft Cloud Revenue Exceeds Expectations', - description: 'Microsoft Azure and Office 365 continue to drive strong growth in the cloud computing sector.', - source: 'CNBC', - timestamp: '6h ago', - imageUrl: '/api/placeholder/400/250', - stockTicker: 'MSFT', - stockChange: -2.15, - stockChangePercent: -0.6 - }, - { - id: '4', - title: 'Federal Reserve Signals Potential Rate Cut', - description: 'The Fed hints at possible interest rate adjustments in response to economic indicators.', - source: 'Wall Street Journal', - timestamp: '8h ago', - imageUrl: '/api/placeholder/400/250' - }, - { - id: '5', - title: 'Google Parent Alphabet Reports Record Revenue', - description: 'Alphabet Inc. posts record quarterly revenue driven by strong advertising growth.', - source: 'Financial Times', - timestamp: '10h ago', - imageUrl: '/api/placeholder/400/250', - stockTicker: 'GOOGL', - stockChange: 15.75, - stockChangePercent: 1.2 - }, - { - id: '6', - title: 'Amazon Expands Prime Delivery Network', - description: 'Amazon announces expansion of its same-day delivery service to 50 new cities.', - source: 'MarketWatch', - timestamp: '12h ago', - imageUrl: '/api/placeholder/400/250', - stockTicker: 'AMZN', - stockChange: 22.40, - stockChangePercent: 1.8 + let news: NewsArticle[] = []; + + // If explicit search query (from search bar) + if (queryOverride) { + news = await newsService.getNews(queryOverride); + } + // Else use tabs + else if (activeTab === 'portfolio') { + if (holdings.length > 0) { + const symbols = holdings.map(h => h.symbol); + news = await newsService.getNews(symbols); + } else { + news = []; } - ]; + } else { + // General market news + news = await newsService.getNews([]); + } - setArticles(mockNews); + setArticles(news); } catch (error) { console.error('Error loading news:', error); } finally { @@ -120,95 +57,86 @@ const NewsPage: React.FC = () => { } }; - const filterArticles = () => { - let filtered = articles; - - // Filter by category - if (selectedCategory !== 'all') { - filtered = filtered.filter(article => - article.title.toLowerCase().includes(selectedCategory) || - article.description.toLowerCase().includes(selectedCategory) - ); - } - - // Filter by search query - if (searchQuery.trim()) { - filtered = filtered.filter(article => - article.title.toLowerCase().includes(searchQuery.toLowerCase()) || - article.description.toLowerCase().includes(searchQuery.toLowerCase()) || - article.source.toLowerCase().includes(searchQuery.toLowerCase()) - ); - } - - setFilteredArticles(filtered); - }; - - const handleSearch = (query: string) => { - setSearchQuery(query); - }; - return (
    -
    -

    Financial News

    -

    Stay updated with the latest market news and analysis

    +
    +
    +

    Financial News

    +

    Live market updates and portfolio insights

    +
    + +
    + +
    +
    + + +
    +
    - -
    - {categories.map(category => ( - - ))} -
    {isLoading ? (
    -

    Loading latest news...

    +

    Loading {activeTab} news...

    ) : (
    - {filteredArticles.map((article, index) => ( -
    - + {articles.length > 0 ? ( + articles.map((article, index) => ( +
    + +
    + )) + ) : ( +
    + {activeTab === 'portfolio' && holdings.length === 0 ? ( +

    Add stocks to your portfolio to see personalized news.

    + ) : ( +

    No articles found.

    + )}
    - ))} -
    - )} - - {!isLoading && filteredArticles.length === 0 && ( -
    -

    No articles found matching your criteria.

    - + )}
    )}
    + + {isSubscriptionModalOpen && ( + setIsSubscriptionModalOpen(false)} /> + )}
    ); }; diff --git a/frontend/src/pages/Portfolio/PortfolioPage.css b/frontend/src/pages/Portfolio/PortfolioPage.css index ac8ca85..10326a9 100644 --- a/frontend/src/pages/Portfolio/PortfolioPage.css +++ b/frontend/src/pages/Portfolio/PortfolioPage.css @@ -15,13 +15,10 @@ } .portfolio-title { - font-size: 1.5rem; + font-size: 2rem; font-weight: 700; + color: #00e6b8; margin: 0; - background: linear-gradient(135deg, #00d4aa, #0099cc); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; } .portfolio-subtitle { @@ -71,7 +68,7 @@ } .summary-value.positive { - color: #00d4aa; + color: #00e6b8; } .summary-value.negative { @@ -84,7 +81,7 @@ } .summary-change.positive { - color: #00d4aa; + color: #00e6b8; } .summary-change.negative { @@ -173,7 +170,7 @@ } .highlight-content .value.positive { - color: #00d4aa; + color: #00e6b8; } .highlight-content .value.negative { @@ -221,7 +218,7 @@ .updating-indicator { font-size: 0.8rem; - color: #00d4aa; + color: #00e6b8; margin-right: 8px; animation: pulse 1.5s infinite; } @@ -288,7 +285,7 @@ } .gain-loss.positive { - color: #00d4aa; + color: #00e6b8; } .gain-loss.negative { @@ -301,7 +298,7 @@ } .gain-percent.positive { - color: #00d4aa; + color: #00e6b8; } .gain-percent.negative { @@ -427,7 +424,7 @@ .form-group input:focus { outline: none; - border-color: #00d4aa; + border-color: #00e6b8; } .search-dropdown-pf { @@ -463,11 +460,26 @@ .item-symbol { font-weight: 700; - color: #00d4aa; + color: #00e6b8; margin-right: 8px; min-width: 50px; } +.item-price { + font-weight: 600; + margin-right: 8px; + min-width: 60px; + text-align: right; +} + +.item-price.positive { + color: #00e6b8; +} + +.item-price.negative { + color: #ff4757; +} + .item-name { flex: 1; color: #fff; @@ -483,8 +495,8 @@ } .selected-preview { - background: rgba(0, 212, 170, 0.1); - border: 1px solid #00d4aa; + background: rgba(0, 230, 184, 0.1); + border: 1px solid #00e6b8; padding: 12px; border-radius: 6px; margin-bottom: 20px; @@ -514,8 +526,8 @@ .loading-bar { text-align: center; padding: 8px; - background: rgba(0, 212, 170, 0.1); - color: #00d4aa; + background: rgba(0, 230, 184, 0.1); + color: #00e6b8; font-size: 0.9rem; margin-bottom: 20px; border-radius: 4px; diff --git a/frontend/src/pages/Portfolio/PortfolioPage.tsx b/frontend/src/pages/Portfolio/PortfolioPage.tsx index 7de89cf..7225d34 100644 --- a/frontend/src/pages/Portfolio/PortfolioPage.tsx +++ b/frontend/src/pages/Portfolio/PortfolioPage.tsx @@ -299,6 +299,11 @@ const PortfolioPage: React.FC = () => {
    handleSelectStock(result)}> {result.symbol} {result.shortname || result.longname} + {result.price !== undefined && ( + = 0 ? 'positive' : 'negative'}`}> + ${result.price.toFixed(2)} + + )} {result.exchange}
    ))} diff --git a/frontend/src/pages/Profile/ProfilePage.css b/frontend/src/pages/Profile/ProfilePage.css index 7751bd3..54dacd6 100644 --- a/frontend/src/pages/Profile/ProfilePage.css +++ b/frontend/src/pages/Profile/ProfilePage.css @@ -32,10 +32,9 @@ } .gradient-text { - font-size: 2.5rem; + font-size: 2rem; font-weight: 700; - color: #fff; - /* Minimalist white instead of gradient */ + color: #00e6b8; margin-bottom: 8px; } @@ -51,8 +50,8 @@ width: 100%; /* width: 100%; already there, just removing max-width */ /* max-width: 1000px; REMOVED */ - background: #111111; - /* Near pitch black for the card */ + background: #1a1a1a; + /* Standard card background */ backdrop-filter: none; /* No blur */ border: 1px solid #333; @@ -87,7 +86,7 @@ display: flex; align-items: center; justify-content: center; - color: #00d4aa; + color: #00e6b8; font-size: 2.5rem; font-weight: bold; border: none; @@ -114,7 +113,7 @@ } .subscription-badge.premium { - background: #00d4aa; + background: #00e6b8; color: #000; border: none; } @@ -235,7 +234,7 @@ } .glass-input:focus { - border-color: #00d4aa; + border-color: #00e6b8; box-shadow: none; } @@ -265,7 +264,7 @@ .upgrade-link { background: none; border: none; - color: #00d4aa; + color: #00e6b8; font-size: 0.9rem; font-weight: 500; cursor: pointer; @@ -300,7 +299,7 @@ } .action-btn.save-btn { - background: #00d4aa; + background: #00e6b8; color: #000; } @@ -374,7 +373,7 @@ } .tab-btn.active { - color: #00d4aa; + color: #00e6b8; } .tab-btn.active::after { @@ -384,7 +383,7 @@ left: 0; width: 100%; height: 2px; - background: #00d4aa; + background: #00e6b8; } /* History Table */ @@ -421,8 +420,8 @@ } .status-badge.success { - background: rgba(0, 212, 170, 0.1); - color: #00d4aa; + background: rgba(0, 230, 184, 0.1); + color: #00e6b8; } .download-btn { diff --git a/frontend/src/services/newsService.ts b/frontend/src/services/newsService.ts new file mode 100644 index 0000000..b62401a --- /dev/null +++ b/frontend/src/services/newsService.ts @@ -0,0 +1,61 @@ + +import axios from 'axios'; + +const LIVE_API_URL = 'http://localhost:3001/api'; + +export interface NewsArticle { + id: string; + title: string; + description: string; + source: string; + timestamp: number; // Unix timestamp + url: string; + imageUrl?: string; + stockTicker?: string; +} + +export const newsService = { + // Fetch general, symbol-specific, or search query news + getNews: async (input: string[] | string): Promise => { + try { + let url = `${LIVE_API_URL}/yahoo/news`; + + if (Array.isArray(input)) { + // It's a list of symbols + const query = input.length > 0 ? input.join(',') : 'market'; + url += `?symbols=${query}`; + } else { + // It's a search term + url += `?q=${encodeURIComponent(input)}`; + } + + const response = await axios.get(url); + return response.data; + } catch (error) { + console.error("News Service Error:", error); + return []; + } + }, + + // Subscribe to email alerts + subscribe: async (email: string, symbols: string[]) => { + try { + const response = await axios.post(`${LIVE_API_URL}/subscribe`, { email, symbols }); + return response.data; + } catch (error) { + console.error("Subscription Error:", error); + throw error; + } + }, + + // Unsubscribe + unsubscribe: async (email: string) => { + try { + const response = await axios.post(`${LIVE_API_URL}/unsubscribe`, { email }); + return response.data; + } catch (error) { + console.error("Unsubscribe Error:", error); + throw error; + } + } +}; diff --git a/frontend/src/services/yahooFinance.ts b/frontend/src/services/yahooFinance.ts index 1a99d8c..80ccf5f 100644 --- a/frontend/src/services/yahooFinance.ts +++ b/frontend/src/services/yahooFinance.ts @@ -8,6 +8,9 @@ export interface YahooSearchResult { longname: string; exchange: string; quoteType: string; + price?: number; + change?: number; + changePercent?: number; } export interface YahooQuote { @@ -30,15 +33,34 @@ export interface YahooHistoryCandle { export const yahooFinanceService = { /** - * Search for stocks using Yahoo Finance API + * Search for stocks using Yahoo Finance API and fetch current prices */ searchStocks: async (query: string): Promise => { try { const response = await axios.get(`${API_BASE_URL}/search`, { params: { q: query } }); - // The Yahoo API structure for search returns data.quotes - return response.data.quotes || []; + // Backend returns { results: [...] } + let results = response.data.results || []; + + // Enhance with prices + if (results.length > 0) { + const symbols = results.map((r: any) => r.symbol); + const quotes = await yahooFinanceService.getMultipleQuotes(symbols); + + results = results.map((r: any) => { + const quote = quotes.find(q => q.symbol === r.symbol); + return { + ...r, + price: quote ? quote.price : null, + change: quote ? (quote.price - quote.previousClose) : null, + changePercent: quote ? ((quote.price - quote.previousClose) / quote.previousClose) * 100 : null + + }; + }); + } + + return results; } catch (error) { console.error('Error searching stocks:', error); return []; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c9c5656..89f780c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -29,6 +29,8 @@ export interface Stock { price: number; change: number; changePercent: number; + exch?: string; + type?: string; } export interface StockTicker {