diff --git a/src/App.tsx b/src/App.tsx index 9e0d424..b62874c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -98,7 +98,7 @@ function AppContent() { path={ROUTES.LOGIN} element={ navigate(ROUTES.EXPLORE)} + onLoginSuccess={() => navigate(ROUTES.DASHBOARD)} onNavigateToSignup={() => navigate(ROUTES.REGISTER)} onNavigateToForgot={() => navigate(ROUTES.FORGOT_PASSWORD)} /> diff --git a/src/components/AuthCallback.tsx b/src/components/AuthCallback.tsx index e2fa298..07521a3 100644 --- a/src/components/AuthCallback.tsx +++ b/src/components/AuthCallback.tsx @@ -12,8 +12,8 @@ export const AuthCallback: React.FC = () => { useEffect(() => { if (isAuthenticated) { // Clean up fragment from url and navigate to dashboard - window.history.replaceState(null, '', '/#/explore'); - navigate(ROUTES.EXPLORE); + window.history.replaceState(null, '', '/#/dashboard'); + navigate(ROUTES.DASHBOARD); } else { navigate(ROUTES.LOGIN); } diff --git a/src/components/PortfolioView.tsx b/src/components/PortfolioView.tsx new file mode 100644 index 0000000..90469ef --- /dev/null +++ b/src/components/PortfolioView.tsx @@ -0,0 +1,278 @@ +import React, { useState, useEffect, useTransition } from 'react'; +import { portfolioService } from '@/services/portfolio.service'; +import type { Portfolio } from '@/services/portfolio.service'; +import '@/styles/components/portfolio.css'; + +export const PortfolioView: React.FC = () => { + const [portfolio, setPortfolio] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [portfolioName, setPortfolioName] = useState(''); + const [newSymbol, setNewSymbol] = useState(''); + const [error, setError] = useState(null); + const [, startTransition] = useTransition(); + + useEffect(() => { + fetchPortfolio(); + }, []); + + const fetchPortfolio = async () => { + setIsLoading(true); + setError(null); + try { + const data = await portfolioService.getPortfolio(); + setPortfolio(data); + } catch (err: any) { + if (err.response?.status !== 404) { + setError(err.response?.data?.detail || 'Failed to fetch portfolio details.'); + } + } finally { + setIsLoading(false); + } + }; + + const handleCreatePortfolio = async (e: React.FormEvent) => { + e.preventDefault(); + if (!portfolioName.trim()) return; + setError(null); + try { + const data = await portfolioService.createPortfolio(portfolioName.trim()); + setPortfolio(data); + setPortfolioName(''); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to create portfolio.'); + } + }; + + const handleAddHolding = async (e: React.FormEvent) => { + e.preventDefault(); + const cleanSymbol = newSymbol.trim().toUpperCase(); + if (!cleanSymbol) return; + setError(null); + try { + const addedHolding = await portfolioService.addHolding(cleanSymbol); + if (portfolio) { + setPortfolio({ + ...portfolio, + holdings: [...portfolio.holdings, addedHolding], + }); + } + setNewSymbol(''); + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to add holding.'); + } + }; + + const handleRemoveHolding = async (symbol: string) => { + setError(null); + try { + await portfolioService.removeHolding(symbol); + if (portfolio) { + setPortfolio({ + ...portfolio, + holdings: portfolio.holdings.filter((h) => h.symbol !== symbol), + }); + } + } catch (err: any) { + setError(err.response?.data?.detail || 'Failed to remove holding.'); + } + }; + + if (isLoading) { + return ( +
+

Loading portfolio details...

+
+ ); + } + + const holdingsCount = portfolio?.holdings?.length || 0; + const isFull = holdingsCount >= 5; + + const formatPrice = (price?: number) => { + if (!price) return '0.00'; + if (price < 0.01) { + return price.toExponential(5); + } + return price.toFixed(2); + }; + + return ( +
+ {error && ( +
+ ⚠️ + {error} +
+ )} + + {/* CASE A: No Portfolio Configured */} + {!portfolio ? ( +
+
+

Configure Portfolio

+
+

+ You don't have an active portfolio. Create one below to start tracking your stock + holdings. +

+
+ setPortfolioName(e.target.value)} + maxLength={100} + required + /> + +
+
+ ) : ( + /* CASE B: Active Portfolio & Holdings Display */ +
+
+
+

{portfolio.name}

+
+ Created: {new Date(portfolio.created_at).toLocaleDateString()} +
+
+
Active Portfolio
+
+ + {/* Capacity Progress Bar */} +
+
+ Holding Slots + {holdingsCount} / 5 +
+
+
= 4 ? 'warning' : ''}`} + style={{ width: `${(holdingsCount / 5) * 100}%` }} + /> +
+
+ + {/* Add Stock Form */} +
+ startTransition(() => setNewSymbol(e.target.value))} + disabled={isFull} + maxLength={10} + required + /> + +
+ {isFull && ( +

+ 💡 Your portfolio capacity is full (5/5 symbols limit). Remove an existing stock to + add a new one. +

+ )} + + {/* Holdings List */} +
+ {holdingsCount === 0 ? ( +
+ No active holdings. Type a ticker symbol above to add your first stock. +
+ ) : ( + portfolio.holdings.map((holding) => ( +
+
+
{holding.symbol.substring(0, 2)}
+
+ {holding.symbol} + + Added: {new Date(holding.created_at).toLocaleDateString()} + +
+
+ + {/* Live Rate Info matching Explore page design */} + {holding.price !== undefined && holding.price !== null && ( +
+
+ + {formatPrice(holding.price)} + + + {holding.currency || 'USD'} + +
+ + {holding.percent_change !== undefined && holding.percent_change !== null && ( +
= 0 ? 'gain' : 'loss'}`} + style={{ + padding: '6px 12px', + borderRadius: '6px', + fontWeight: 700, + fontSize: '0.85rem', + minWidth: '75px', + textAlign: 'center', + background: + holding.percent_change >= 0 + ? 'rgba(16, 185, 129, 0.1)' + : 'rgba(239, 68, 68, 0.1)', + border: + holding.percent_change >= 0 + ? '1px solid rgba(16, 185, 129, 0.2)' + : '1px solid rgba(239, 68, 68, 0.2)', + color: holding.percent_change >= 0 ? '#10b981' : '#ef4444', + }} + > + {holding.percent_change >= 0 ? '+' : ''} + {holding.percent_change.toFixed(2)}% +
+ )} +
+ )} + + +
+ )) + )} +
+
+ )} +
+ ); +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..a9959ab --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,254 @@ +import React from 'react'; +import { useNavigate, useLocation } from 'react-router-dom'; +import type { UserResponse } from '@/features/auth/auth.types'; +import { ROUTES } from '@/constants/routes.constants'; + +interface SidebarProps { + user: UserResponse | null; + onLogout: () => void; +} + +export const Sidebar: React.FC = ({ user, onLogout }) => { + const navigate = useNavigate(); + const location = useLocation(); + + const isDashboardActive = location.pathname === ROUTES.DASHBOARD; + const isExploreActive = location.pathname === ROUTES.EXPLORE; + + return ( + + ); +}; diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index b63a8d5..c7fb42c 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -1,5 +1,7 @@ import React from 'react'; import type { UserResponse } from '@/features/auth/auth.types'; +import { PortfolioView } from '@/components/PortfolioView'; +import { Sidebar } from '@/components/Sidebar'; import '@/styles/components/dashboard.css'; interface DashboardPageProps { @@ -12,57 +14,30 @@ interface DashboardPageProps { */ export const DashboardPage: React.FC = ({ user, onLogout }) => { return ( -
-
-

Welcome, {user?.username || 'Trader'}!

-

- You have successfully logged in to AI Trading Discipline Copilot. -

+
+ {/* Sidebar Panel */} + - {user && ( -
-

- Profile Details -

-

- Email: {user.email} -

-

- Role: {user.role} -

-

- Verified: {user.is_verified ? '✅ Yes' : '❌ No'} -

-

- Account created: {new Date(user.created_at).toLocaleDateString()} -

-
- )} + {/* Main Content Area */} +
+
+

+ Welcome back, {user?.username || 'Trader'}! +

+

+ Monitor and manage your active stock portfolios in real-time. +

- -
-
+ +
+
+
); }; diff --git a/src/features/globe/GlobeView.tsx b/src/features/globe/GlobeView.tsx index 984e64b..0ac459e 100644 --- a/src/features/globe/GlobeView.tsx +++ b/src/features/globe/GlobeView.tsx @@ -80,7 +80,9 @@ export const GlobeView: React.FC = ({ onEnterApp }) => {
- +
@@ -110,7 +114,18 @@ export const GlobeView: React.FC = ({ onEnterApp }) => { Save up to 17% 🤑 - + diff --git a/src/features/globe/useGlobe.ts b/src/features/globe/useGlobe.ts index bc5d007..f30f812 100644 --- a/src/features/globe/useGlobe.ts +++ b/src/features/globe/useGlobe.ts @@ -102,35 +102,35 @@ export function useGlobe(containerRef: RefObject) { let scrollAngle = 0; const handleWheel = (e: WheelEvent) => { if (!world) return; - + const currentPov = world.pointOfView(); let newAltitude = currentPov.altitude; - + // Calculate new altitude (zoom) // Reverse logic: Scroll DOWN (deltaY > 0) -> Zoom IN (decrease altitude) // Scroll UP (deltaY < 0) -> Zoom OUT (increase altitude) const zoomSpeed = 0.002; newAltitude -= e.deltaY * zoomSpeed; - + // Bounds: 1.5 = Large globe (Image 1), 4.0 = Small globe (Image 2) if (newAltitude < 1.5) newAltitude = 1.5; if (newAltitude > 4.0) newAltitude = 4.0; - - // If scrolling UP and the globe is already at its smallest (4.0), + + // If scrolling UP and the globe is already at its smallest (4.0), // release the scroll event so the user scrolls back to the top of the page! if (e.deltaY < 0 && currentPov.altitude >= 3.99) { - return; + return; } - + // Otherwise, prevent page scroll and apply zoom & rotation to the globe e.preventDefault(); - + // Rotation scrollAngle += e.deltaY * GLOBE_CONFIG.SCROLL_SENSITIVITY; - + world.pointOfView({ lat: currentPov.lat, lng: scrollAngle, altitude: newAltitude }); }; - + // Attach to container, passive: false so we can preventDefault container.addEventListener('wheel', handleWheel, { passive: false }); diff --git a/src/index.css b/src/index.css index 96451c6..0df55a9 100644 --- a/src/index.css +++ b/src/index.css @@ -4,7 +4,7 @@ body { padding: 0; background-color: #000; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - overflow: hidden; + overflow: auto; color: #fff; } @@ -236,25 +236,77 @@ body { width: 350px; height: 1.5px; background: linear-gradient(90deg, #ff00ff 0%, transparent 100%); - box-shadow: 0 0 12px #ff00ff, 0 0 4px #ff00ff; + box-shadow: + 0 0 12px #ff00ff, + 0 0 4px #ff00ff; animation: comet-shoot 18s linear infinite; } -.comet-1 { top: 15%; left: 100%; animation-delay: 0s; animation-duration: 20s; } -.comet-2 { top: 45%; left: 100%; animation-delay: 12s; animation-duration: 22s; } -.comet-3 { top: 75%; left: 100%; animation-delay: 5s; animation-duration: 19s; } -.comet-4 { top: 25%; left: 100%; animation-delay: 17s; animation-duration: 24s; } -.comet-5 { top: 85%; left: 100%; animation-delay: 8s; animation-duration: 21s; } - -.comet-violet-1 { top: 30%; left: 100%; animation-delay: 8s; animation-duration: 18s; } -.comet-violet-2 { top: 60%; left: 100%; animation-delay: 2s; animation-duration: 23s; } -.comet-violet-3 { top: 10%; left: 100%; animation-delay: 14s; animation-duration: 20s; } +.comet-1 { + top: 15%; + left: 100%; + animation-delay: 0s; + animation-duration: 20s; +} +.comet-2 { + top: 45%; + left: 100%; + animation-delay: 12s; + animation-duration: 22s; +} +.comet-3 { + top: 75%; + left: 100%; + animation-delay: 5s; + animation-duration: 19s; +} +.comet-4 { + top: 25%; + left: 100%; + animation-delay: 17s; + animation-duration: 24s; +} +.comet-5 { + top: 85%; + left: 100%; + animation-delay: 8s; + animation-duration: 21s; +} + +.comet-violet-1 { + top: 30%; + left: 100%; + animation-delay: 8s; + animation-duration: 18s; +} +.comet-violet-2 { + top: 60%; + left: 100%; + animation-delay: 2s; + animation-duration: 23s; +} +.comet-violet-3 { + top: 10%; + left: 100%; + animation-delay: 14s; + animation-duration: 20s; +} @keyframes comet-shoot { - 0% { transform: rotate(-45deg) translateX(0); opacity: 0; } - 5% { opacity: 1; } - 10% { transform: rotate(-45deg) translateX(-150vw); opacity: 0; } - 100% { opacity: 0; } + 0% { + transform: rotate(-45deg) translateX(0); + opacity: 0; + } + 5% { + opacity: 1; + } + 10% { + transform: rotate(-45deg) translateX(-150vw); + opacity: 0; + } + 100% { + opacity: 0; + } } /* ========================================================================== @@ -288,7 +340,9 @@ body { background-color: #fff; border-radius: 50%; opacity: 0; - box-shadow: 0 0 14px rgba(255, 255, 255, 1), 0 0 4px #60a5fa; + box-shadow: + 0 0 14px rgba(255, 255, 255, 1), + 0 0 4px #60a5fa; animation: blink-star 12s infinite; } @@ -299,40 +353,129 @@ body { background-color: #fff; border-radius: 50%; opacity: 0; - box-shadow: 0 0 20px rgba(255, 255, 255, 1), 0 0 8px #c084fc; + box-shadow: + 0 0 20px rgba(255, 255, 255, 1), + 0 0 8px #c084fc; animation: blink-star 10s infinite; } /* Base stars */ -.star-1 { top: 15%; left: 20%; animation-delay: 0s; } -.star-2 { top: 35%; left: 75%; animation-delay: 2s; } -.star-3 { top: 60%; left: 15%; animation-delay: 4s; } -.star-4 { top: 80%; left: 85%; animation-delay: 6s; } -.star-5 { top: 25%; left: 50%; animation-delay: 8s; } -.star-6 { top: 70%; left: 45%; animation-delay: 10s; } -.star-7 { top: 45%; left: 10%; animation-delay: 12s; } -.star-8 { top: 5%; left: 40%; animation-delay: 1s; } -.star-9 { top: 90%; left: 20%; animation-delay: 3s; } -.star-10 { top: 50%; left: 90%; animation-delay: 7s; } +.star-1 { + top: 15%; + left: 20%; + animation-delay: 0s; +} +.star-2 { + top: 35%; + left: 75%; + animation-delay: 2s; +} +.star-3 { + top: 60%; + left: 15%; + animation-delay: 4s; +} +.star-4 { + top: 80%; + left: 85%; + animation-delay: 6s; +} +.star-5 { + top: 25%; + left: 50%; + animation-delay: 8s; +} +.star-6 { + top: 70%; + left: 45%; + animation-delay: 10s; +} +.star-7 { + top: 45%; + left: 10%; + animation-delay: 12s; +} +.star-8 { + top: 5%; + left: 40%; + animation-delay: 1s; +} +.star-9 { + top: 90%; + left: 20%; + animation-delay: 3s; +} +.star-10 { + top: 50%; + left: 90%; + animation-delay: 7s; +} /* Big stars */ -.big-star-1 { top: 20%; left: 80%; animation-delay: 1s; } -.big-star-2 { top: 50%; left: 25%; animation-delay: 5s; } -.big-star-3 { top: 85%; left: 60%; animation-delay: 9s; } -.big-star-4 { top: 10%; left: 10%; animation-delay: 3s; } -.big-star-5 { top: 40%; left: 60%; animation-delay: 11s; } -.big-star-6 { top: 75%; left: 80%; animation-delay: 7s; } +.big-star-1 { + top: 20%; + left: 80%; + animation-delay: 1s; +} +.big-star-2 { + top: 50%; + left: 25%; + animation-delay: 5s; +} +.big-star-3 { + top: 85%; + left: 60%; + animation-delay: 9s; +} +.big-star-4 { + top: 10%; + left: 10%; + animation-delay: 3s; +} +.big-star-5 { + top: 40%; + left: 60%; + animation-delay: 11s; +} +.big-star-6 { + top: 75%; + left: 80%; + animation-delay: 7s; +} /* Mega stars */ -.mega-star-1 { top: 30%; left: 15%; animation-delay: 2s; } -.mega-star-2 { top: 65%; left: 70%; animation-delay: 6s; } -.mega-star-3 { top: 95%; left: 45%; animation-delay: 10s; } +.mega-star-1 { + top: 30%; + left: 15%; + animation-delay: 2s; +} +.mega-star-2 { + top: 65%; + left: 70%; + animation-delay: 6s; +} +.mega-star-3 { + top: 95%; + left: 45%; + animation-delay: 10s; +} @keyframes blink-star { - 0% { opacity: 0; transform: scale(0.5); } - 5% { opacity: 1; transform: scale(1.5); } - 10% { opacity: 0; transform: scale(0.5); } - 100% { opacity: 0; } + 0% { + opacity: 0; + transform: scale(0.5); + } + 5% { + opacity: 1; + transform: scale(1.5); + } + 10% { + opacity: 0; + transform: scale(0.5); + } + 100% { + opacity: 0; + } } /* ========================================================================== @@ -348,7 +491,7 @@ body { scroll-behavior: smooth; scroll-snap-type: y mandatory; /* Apply the night sky background to the entire scrollable page */ - background-image: url("https://unpkg.com/three-globe/example/img/night-sky.png"); + background-image: url('https://unpkg.com/three-globe/example/img/night-sky.png'); background-size: cover; background-position: center; background-attachment: fixed; @@ -390,7 +533,12 @@ body { max-height: 800px; border-radius: 50%; /* Cyan and Purple glow */ - background: radial-gradient(circle, rgba(147, 51, 234, 0.4) 0%, rgba(59, 130, 246, 0.25) 40%, transparent 70%); + background: radial-gradient( + circle, + rgba(147, 51, 234, 0.4) 0%, + rgba(59, 130, 246, 0.25) 40%, + transparent 70% + ); filter: blur(80px); z-index: 4; /* Behind globe */ pointer-events: none; @@ -535,15 +683,15 @@ body { transition: all 0.2s; } -input[type="radio"] { +input[type='radio'] { display: none; } -input[type="radio"]:checked + .radio-custom { +input[type='radio']:checked + .radio-custom { border-color: #fff; } -input[type="radio"]:checked + .radio-custom::after { +input[type='radio']:checked + .radio-custom::after { content: ''; position: absolute; top: 50%; diff --git a/src/lib/api.client.ts b/src/lib/api.client.ts index 6ef745c..be59b2c 100644 --- a/src/lib/api.client.ts +++ b/src/lib/api.client.ts @@ -34,42 +34,37 @@ apiClient.interceptors.request.use((config) => { return config; }); -// Response Interceptor: Handle 401s and automatic token refresh -apiClient.interceptors.response.use( - (response) => response, - async (error) => { - const originalRequest = error.config; +// Reusable response interceptor to handle 401s and automatic token refresh +const handleResponseError = (client: any) => async (error: any) => { + const originalRequest = error.config; - // If it's a 401 and we haven't already retried this request - if (error.response?.status === 401 && !originalRequest._retry) { - // Prevent infinite loops on the refresh endpoint itself - if (originalRequest.url === '/auth/refresh') { - // Refresh failed (cookie expired/invalid) -> force logout - useUserStore.getState().logout(); - return Promise.reject(error); - } + if (error.response?.status === 401 && !originalRequest._retry) { + if (originalRequest.url?.includes('/auth/refresh')) { + useUserStore.getState().logout(); + return Promise.reject(error); + } - originalRequest._retry = true; + originalRequest._retry = true; - try { - // Attempt to get a new access token using the HttpOnly cookie - const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true }); + try { + const { data } = await axios.post(`${API_URL}/auth/refresh`, {}, { withCredentials: true }); + const newAccessToken = data.access_token; + useUserStore.getState().setAccessToken(newAccessToken); - const newAccessToken = data.access_token; + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return client(originalRequest); + } catch (refreshError) { + useUserStore.getState().logout(); + return Promise.reject(refreshError); + } + } - // Update the store with the new token - useUserStore.getState().setAccessToken(newAccessToken); + return Promise.reject(error); +}; - // Update the original request's header and retry - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - return apiClient(originalRequest); - } catch (refreshError) { - // Refresh failed (e.g., cookie expired) -> logout - useUserStore.getState().logout(); - return Promise.reject(refreshError); - } - } +apiClient.interceptors.response.use((response) => response, handleResponseError(apiClient)); - return Promise.reject(error); - }, +marketApiClient.interceptors.response.use( + (response) => response, + handleResponseError(marketApiClient), ); diff --git a/src/pages/LiveMarketDashboard.tsx b/src/pages/LiveMarketDashboard.tsx index 1627a06..aaf10ed 100644 --- a/src/pages/LiveMarketDashboard.tsx +++ b/src/pages/LiveMarketDashboard.tsx @@ -1,133 +1,387 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useUserStore } from '@/stores/userStore'; +import { portfolioService } from '@/services/portfolio.service'; +import { Sidebar } from '@/components/Sidebar'; +import { ROUTES } from '@/constants/routes.constants'; import '@/styles/components/live-market.css'; const MARKET_WS_URL = import.meta.env.VITE_MARKET_WS_BASE_URL || 'ws://localhost:8001'; export const LiveMarketDashboard: React.FC = () => { - const [marketData, setMarketData] = useState({ gainers: [], losers: [] }); - const [activeTab, setActiveTab] = useState('Stocks'); - - useEffect(() => { - // 1. Connect to the Market Service WebSocket - const ws = new WebSocket(`${MARKET_WS_URL}/dashboard/ws/market`); - - // 2. Listen for live updates being pushed from FastAPI - ws.onmessage = (event) => { - try { - const liveData = JSON.parse(event.data); - console.log("Live Update Received:", liveData); - // Instantly update the React state - setMarketData(liveData); - } catch (err) { - console.error("Error parsing websocket message", err); - } - }; - - // 3. Clean up the connection if the user leaves the page - return () => ws.close(); - }, []); - - const formatPrice = (price: number) => { - if (!price) return "0.00"; - if (price < 0.01) { - return price.toExponential(5); - } - return price.toFixed(2); - }; + const navigate = useNavigate(); + const [marketData, setMarketData] = useState({ gainers: [], losers: [] }); + const [activeTab, setActiveTab] = useState('Stocks'); + const [portfolioHoldings, setPortfolioHoldings] = useState([]); + const [feedback, setFeedback] = useState<{ message: string; type: 'success' | 'error' } | null>( + null, + ); + + const { user, logout, isAuthenticated } = useUserStore(); + + const handleLogout = async () => { + await logout(); + navigate(ROUTES.HOME); + }; - const getInitials = (symbol: string) => { - return symbol.substring(0, 2).toUpperCase(); + // 1. Listen to market data WebSocket + useEffect(() => { + const ws = new WebSocket(`${MARKET_WS_URL}/dashboard/ws/market`); + + ws.onmessage = (event) => { + try { + const liveData = JSON.parse(event.data); + console.log('Live Update Received:', liveData); + setMarketData(liveData); + } catch (err) { + console.error('Error parsing websocket message', err); + } }; - return ( -
-
-
- {['Stocks', 'Crypto', 'Futures', 'Forex', 'Economy', 'Brokers'].map(tab => ( - - ))} -
-
- -
- {/* Gainers */} -
-
-

- {activeTab === 'Stocks' ? 'Stock' : activeTab} gainers - > -

-
+ return () => ws.close(); + }, []); -
- {marketData.gainers && marketData.gainers.length > 0 ? ( - marketData.gainers.map((stock: any) => ( -
-
-
{getInitials(stock.symbol)}
-
-
{stock.symbol.split('.')[0]}
-
{stock.symbol}
-
-
-
- {formatPrice(stock.price)} - {stock.currency || 'USD'} -
-
-
+{Math.abs(stock.percent_change).toFixed(2)}%
-
-
- )) - ) : ( -
Waiting for data...
- )} -
-
+ // 2. Fetch user's portfolio holdings if authenticated + useEffect(() => { + if (isAuthenticated) { + fetchPortfolioHoldings(); + } + }, [isAuthenticated]); + + const fetchPortfolioHoldings = async () => { + try { + const data = await portfolioService.getPortfolio(); + setPortfolioHoldings(data.holdings.map((h) => h.symbol)); + } catch (err) { + // 404 is normal if they haven't configured a portfolio yet + } + }; + + const handleAddStockToPortfolio = async (symbol: string) => { + setFeedback(null); + try { + // Step 1: Create a default portfolio if they don't have one + try { + await portfolioService.getPortfolio(); + } catch (err: any) { + if (err.response?.status === 404) { + await portfolioService.createPortfolio('My Portfolio'); + } else { + throw err; + } + } + + // Step 2: Add the holding + await portfolioService.addHolding(symbol); + setPortfolioHoldings((prev) => [...prev, symbol]); + + setFeedback({ message: `Successfully added ${symbol} to your portfolio!`, type: 'success' }); + setTimeout(() => setFeedback(null), 4000); + } catch (err: any) { + const errorMsg = err.response?.data?.detail || `Failed to add ${symbol} to portfolio.`; + setFeedback({ message: errorMsg, type: 'error' }); + setTimeout(() => setFeedback(null), 5000); + } + }; + + const formatPrice = (price: number) => { + if (!price) return '0.00'; + if (Math.abs(price) < 0.01) { + return price.toExponential(5); + } + return price.toFixed(2); + }; - {/* Losers */} -
-
-

- {activeTab === 'Stocks' ? 'Stock' : activeTab} losers - > -

+ const formatPercent = (percent: number) => { + if (!percent) return '0.00'; + return percent.toFixed(2); + }; + + const getInitials = (symbol: string) => { + return symbol.substring(0, 2).toUpperCase(); + }; + + const dashboardContent = ( +
+ {/* Dynamic Feedback Banner */} + {feedback && ( +
+ {feedback.type === 'success' ? '✅ ' : '⚠️ '} + {feedback.message} +
+ )} + + {/* Guest Logo Header */} + {!isAuthenticated && ( +
+
+
+

+ TRADING COPILOT +

+
+ +
+ )} + +
+
+ +
+ {['Stocks', 'Crypto', 'Futures', 'Forex', 'Economy', 'Brokers'].map((tab) => ( + + ))} +
+ +
+ {isAuthenticated && ( + + )} +
+
+ +
+ {/* Gainers */} +
+
+

+ {activeTab === 'Stocks' ? 'Stock' : activeTab} gainers + > +

+
+
+ {marketData.gainers && marketData.gainers.length > 0 ? ( + marketData.gainers.map((stock: any) => ( +
+
+
{getInitials(stock.symbol)}
+
+
{stock.symbol}
+
+
+ {formatPrice(stock.price)} + + +{formatPercent(stock.percent_change)}% + + {isAuthenticated && ( + + )} +
+
+ )) + ) : ( +
Waiting for data...
+ )} +
+
-
- {marketData.losers && marketData.losers.length > 0 ? ( - marketData.losers.map((stock: any) => ( -
-
-
{getInitials(stock.symbol)}
-
-
{stock.symbol.split('.')[0]}
-
{stock.symbol}
-
-
-
- {formatPrice(stock.price)} - {stock.currency || 'USD'} -
-
-
- -{Math.abs(stock.percent_change).toFixed(2)}% -
-
-
- )) - ) : ( -
Waiting for data...
- )} + {/* Losers */} +
+
+

+ {activeTab === 'Stocks' ? 'Stock' : activeTab} losers + > +

+
+
+ {marketData.losers && marketData.losers.length > 0 ? ( + marketData.losers.map((stock: any) => ( +
+
+
{getInitials(stock.symbol)}
+
+
{stock.symbol}
+
+
+ {formatPrice(stock.price)} + + {formatPercent(stock.percent_change)}% + + {isAuthenticated && ( + + )} +
-
+ )) + ) : ( +
Waiting for data...
+ )} +
+
+
+ ); + + if (isAuthenticated) { + return ( +
+ +
{dashboardContent}
+
); + } + + return dashboardContent; }; diff --git a/src/services/portfolio.service.ts b/src/services/portfolio.service.ts new file mode 100644 index 0000000..d1b2b3b --- /dev/null +++ b/src/services/portfolio.service.ts @@ -0,0 +1,43 @@ +import { marketApiClient } from '@/lib/api.client'; + +export interface Holding { + id: string; + symbol: string; + created_at: string; + price?: number; + percent_change?: number; + currency?: string; +} + +export interface Portfolio { + id: string; + name: string; + created_at: string; + updated_at: string; + holdings: Holding[]; +} + +export const portfolioService = { + // Fetch user's active portfolio + async getPortfolio(): Promise { + const { data } = await marketApiClient.get('/portfolio'); + return data; + }, + + // Create a new portfolio + async createPortfolio(name: string): Promise { + const { data } = await marketApiClient.post('/portfolio', { name }); + return data; + }, + + // Add a new stock holding + async addHolding(symbol: string): Promise { + const { data } = await marketApiClient.post('/portfolio/holdings', { symbol }); + return data; + }, + + // Remove a stock holding + async removeHolding(symbol: string): Promise { + await marketApiClient.delete(`/portfolio/holdings/${symbol}`); + }, +}; diff --git a/src/styles/components/live-market.css b/src/styles/components/live-market.css index ad31869..e2ed349 100644 --- a/src/styles/components/live-market.css +++ b/src/styles/components/live-market.css @@ -3,10 +3,10 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); .market-dashboard { - background-color: #ffffff; + background-color: var(--color-bg-primary, #0b1120); min-height: 100vh; padding: 30px 40px; - color: #111111; + color: var(--color-text-primary, #ffffff); font-family: 'Inter', sans-serif; box-sizing: border-box; } @@ -19,12 +19,12 @@ .nav-pills { display: flex; - background: #ffffff; - border: 1px solid #eaeaea; + background: var(--color-bg-secondary, #0f172a); + border: 1px solid var(--color-border-subtle, #1e293b); border-radius: 40px; padding: 4px; gap: 4px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } .nav-pill { @@ -34,16 +34,17 @@ border-radius: 40px; font-size: 0.9rem; font-weight: 500; - color: #555555; + color: var(--color-text-secondary, #9ca3af); cursor: pointer; + transition: all 0.2s ease; } .nav-pill:hover { - color: #111111; + color: var(--color-text-primary, #ffffff); } .nav-pill.active { - background: #2a2a2a; + background: var(--color-brand-teal, #0d9488); color: #ffffff; } @@ -56,7 +57,11 @@ } .market-list-section { - background: #ffffff; + background: var(--color-bg-secondary, #0f172a); + border: 1px solid var(--color-border-subtle, #1e293b); + border-radius: 16px; + padding: 24px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); } .section-header { @@ -67,43 +72,43 @@ font-size: 1.5rem; font-weight: 700; margin: 0; - color: #111111; + color: var(--color-text-primary, #ffffff); display: flex; align-items: center; } .heading-arrow { - margin-left: 2px; + margin-left: 6px; font-size: 1.4rem; font-weight: 600; - color: #111111; + color: var(--color-brand-teal, #0d9488); } -.stock-list { +.market-list { display: flex; flex-direction: column; } -.stock-row { +.market-row { display: flex; justify-content: space-between; align-items: center; - padding: 14px 0; - border-bottom: 1px solid #f6f6f6; + padding: 16px 0; + border-bottom: 1px solid var(--color-border-subtle, #1e293b); } -.stock-row:last-child { +.market-row:last-child { border-bottom: none; } -.stock-left { +.stock-info-col { display: flex; align-items: center; gap: 12px; width: 220px; } -.stock-icon { +.stock-icon-circle { width: 38px; height: 38px; border-radius: 50%; @@ -113,94 +118,64 @@ color: #ffffff; font-size: 0.9rem; font-weight: 700; -} - -.icon-blue { - background-color: #0d6de0; -} - -.icon-purple { - background-color: #6a309e; -} - -.stock-names { - display: flex; - flex-direction: column; - gap: 4px; -} - -.stock-name { - font-weight: 500; - font-size: 0.95rem; - color: #111111; + background-color: rgba(13, 148, 136, 0.15); + border: 1px solid rgba(13, 148, 136, 0.3); + text-transform: uppercase; } .stock-ticker { - background-color: #f7f7f7; - color: #333333; - font-size: 0.65rem; + background-color: rgba(255, 255, 255, 0.05); + color: var(--color-text-secondary, #9ca3af); + font-size: 0.7rem; font-weight: 600; - padding: 2px 6px; - border-radius: 4px; + padding: 3px 8px; + border-radius: 6px; display: inline-block; width: fit-content; text-transform: uppercase; - letter-spacing: 0.2px; + letter-spacing: 0.5px; + border: 1px solid rgba(255, 255, 255, 0.08); } -.stock-middle { +.stock-pricing-col { display: flex; - align-items: baseline; - gap: 2px; + align-items: center; + gap: 12px; flex: 1; justify-content: flex-end; - padding-right: 40px; -} - -.price { - font-weight: 500; - font-size: 0.95rem; - color: #111111; } -.currency { - font-size: 0.6rem; - color: #555555; +.stock-current-price { font-weight: 600; + font-size: 0.95rem; + color: var(--color-text-primary, #ffffff); } -.stock-right { - display: flex; - justify-content: flex-end; - width: 90px; -} - -.pill-gain { - background-color: #1d9c73; - color: #ffffff; +.stock-change-pill { font-weight: 600; font-size: 0.8rem; padding: 4px 8px; - border-radius: 4px; - min-width: 60px; + border-radius: 6px; + min-width: 65px; text-align: center; } -.pill-loss { - background-color: #cc3a4a; - color: #ffffff; - font-weight: 600; - font-size: 0.8rem; - padding: 4px 8px; - border-radius: 4px; - min-width: 60px; - text-align: center; +.stock-change-pill.positive { + background-color: rgba(16, 185, 129, 0.15); + color: #10b981; + border: 1px solid rgba(16, 185, 129, 0.25); +} + +.stock-change-pill.negative { + background-color: rgba(239, 68, 68, 0.15); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.25); } .loading-state { text-align: center; padding: 40px; - color: #888888; + color: var(--color-text-secondary, #9ca3af); } @media (max-width: 1200px) { @@ -208,4 +183,4 @@ grid-template-columns: 1fr; gap: 40px; } -} \ No newline at end of file +} diff --git a/src/styles/components/portfolio.css b/src/styles/components/portfolio.css new file mode 100644 index 0000000..c2bcb25 --- /dev/null +++ b/src/styles/components/portfolio.css @@ -0,0 +1,228 @@ +/* Portfolio View Styling - Sleek Glassmorphism Design */ + +.portfolio-card { + background: var(--color-bg-secondary); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid var(--color-border-subtle); + border-radius: 16px; + padding: 24px; + margin-top: 24px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + color: #f3f4f6; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.portfolio-header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 16px; + margin-bottom: 20px; +} + +.portfolio-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-primary); + margin: 0; +} + +.portfolio-date { + font-size: 0.85rem; + color: #9ca3af; +} + +/* Capacity Indicator */ +.capacity-container { + margin: 16px 0; +} + +.capacity-label { + display: flex; + justify-content: space-between; + font-size: 0.85rem; + color: #9ca3af; + margin-bottom: 6px; +} + +.capacity-bar-bg { + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.05); + border-radius: 9999px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.05); +} + +.capacity-bar-fill { + height: 100%; + border-radius: 9999px; + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); + background: var(--color-brand-teal); +} + +.capacity-bar-fill.warning { + background: linear-gradient(90deg, #f59e0b, #ef4444); +} + +/* Forms & Inputs */ +.portfolio-form { + display: flex; + gap: 12px; + margin-top: 12px; +} + +.portfolio-input { + flex: 1; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px 16px; + color: #ffffff; + outline: none; + font-size: 0.95rem; + transition: all 0.25s ease; +} + +.portfolio-input:focus { + border-color: var(--color-brand-teal); + background: rgba(255, 255, 255, 0.06); + box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.2); +} + +.portfolio-btn { + background: var(--color-brand-teal); + color: #ffffff; + border: none; + border-radius: 8px; + padding: 10px 20px; + font-weight: 600; + cursor: pointer; + transition: all 0.25s ease; + box-shadow: 0 4px 12px rgba(13, 148, 136, 0.2); +} + +.portfolio-btn:hover:not(:disabled) { + transform: translateY(-1px); + background-color: var(--color-brand-teal-hover); + box-shadow: 0 6px 16px rgba(13, 148, 136, 0.35); +} + +.portfolio-btn:active:not(:disabled) { + transform: translateY(0); +} + +.portfolio-btn:disabled { + background: rgba(255, 255, 255, 0.08); + color: #6b7280; + cursor: not-allowed; + box-shadow: none; +} + +/* Holdings List */ +.holdings-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; +} + +.holding-item { + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 12px; + padding: 14px 20px; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); +} + +.holding-item:hover { + background: rgba(255, 255, 255, 0.04); + border-color: var(--color-brand-teal); + transform: translateX(4px); +} + +.holding-info { + display: flex; + align-items: center; + gap: 16px; +} + +.holding-avatar { + background: rgba(13, 148, 136, 0.1); + border: 1px solid rgba(13, 148, 136, 0.3); + color: var(--color-brand-teal); + font-weight: 700; + font-size: 0.9rem; + width: 40px; + height: 40px; + border-radius: 8px; + display: flex; + justify-content: center; + align-items: center; + letter-spacing: 0.05em; +} + +.holding-details { + display: flex; + flex-direction: column; +} + +.holding-symbol { + font-weight: 700; + font-size: 1.1rem; + color: #ffffff; +} + +.holding-date { + font-size: 0.75rem; + color: #9ca3af; + margin-top: 2px; +} + +.remove-btn { + background: transparent; + color: #f87171; + border: 1px solid rgba(248, 113, 113, 0.2); + border-radius: 6px; + padding: 6px 12px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.remove-btn:hover { + background: rgba(248, 113, 113, 0.1); + border-color: #ef4444; + color: #ef4444; +} + +/* Notifications / Alert Banner */ +.alert-banner { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.25); + color: #fca5a5; + padding: 12px 16px; + border-radius: 8px; + font-size: 0.9rem; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; +} + +.alert-icon { + font-weight: bold; +} + +.empty-state { + text-align: center; + padding: 40px 20px; + color: #9ca3af; + font-size: 0.95rem; +}