From f42111fc8ab3e83c93015a98a0e5ee40cbc908b5 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 21:16:23 +0530 Subject: [PATCH 01/11] feat(frontend): integrate portfolio management and holdings tracking --- src/components/PortfolioView.tsx | 210 +++++++++++++++++++++ src/features/dashboard/DashboardPage.tsx | 142 +++++++++----- src/lib/api.client.ts | 57 +++--- src/services/portfolio.service.ts | 40 ++++ src/styles/components/portfolio.css | 229 +++++++++++++++++++++++ 5 files changed, 605 insertions(+), 73 deletions(-) create mode 100644 src/components/PortfolioView.tsx create mode 100644 src/services/portfolio.service.ts create mode 100644 src/styles/components/portfolio.css diff --git a/src/components/PortfolioView.tsx b/src/components/PortfolioView.tsx new file mode 100644 index 0000000..ab8b994 --- /dev/null +++ b/src/components/PortfolioView.tsx @@ -0,0 +1,210 @@ +import React, { useState, useEffect, useTransition } from 'react'; +import { portfolioService, 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; + + 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()} + +
+
+ +
+ )) + )} +
+
+ )} +
+ ); +}; diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index b63a8d5..d6e52c4 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -1,5 +1,6 @@ import React from 'react'; import type { UserResponse } from '@/features/auth/auth.types'; +import { PortfolioView } from '@/components/PortfolioView'; import '@/styles/components/dashboard.css'; interface DashboardPageProps { @@ -12,56 +13,113 @@ interface DashboardPageProps { */ export const DashboardPage: React.FC = ({ user, onLogout }) => { return ( -
-
+
+

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

-

+

You have successfully logged in to AI Trading Discipline Copilot.

- {user && ( -
-

+ {/* Profile Card */} + {user && ( +
- Profile Details -

-

- Email: {user.email} -

-

- Role: {user.role} -

-

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

-

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

-
- )} +
+

+ Profile Details +

+

+ Email: {user.email} +

+

+ Role: {user.role} +

+

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

+

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

+
- + +
+ )} + + {/* Portfolio Management Card */} +
+ +
+
); 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/services/portfolio.service.ts b/src/services/portfolio.service.ts new file mode 100644 index 0000000..6e41ccf --- /dev/null +++ b/src/services/portfolio.service.ts @@ -0,0 +1,40 @@ +import { marketApiClient } from '@/lib/api.client'; + +export interface Holding { + id: string; + symbol: string; + created_at: 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/portfolio.css b/src/styles/components/portfolio.css new file mode 100644 index 0000000..864cc94 --- /dev/null +++ b/src/styles/components/portfolio.css @@ -0,0 +1,229 @@ +/* Portfolio View Styling - Sleek Glassmorphism Design */ + +.portfolio-card { + background: rgba(17, 24, 39, 0.7); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + 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; + background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + 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: linear-gradient(90deg, #3b82f6, #10b981); +} + +.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: #3b82f6; + background: rgba(255, 255, 255, 0.06); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +} + +.portfolio-btn { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + 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(37, 99, 235, 0.2); +} + +.portfolio-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(37, 99, 235, 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: rgba(59, 130, 246, 0.2); + transform: translateX(4px); +} + +.holding-info { + display: flex; + align-items: center; + gap: 16px; +} + +.holding-avatar { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.2) 100%); + border: 1px solid rgba(59, 130, 246, 0.3); + color: #60a5fa; + 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; +} From b7a03300ce315608b01bc36d297170fe1537911b Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 21:26:31 +0530 Subject: [PATCH 02/11] fix(frontend): import Portfolio interface as type to prevent runtime SyntaxError --- src/components/PortfolioView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/PortfolioView.tsx b/src/components/PortfolioView.tsx index ab8b994..64337df 100644 --- a/src/components/PortfolioView.tsx +++ b/src/components/PortfolioView.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useTransition } from 'react'; -import { portfolioService, Portfolio } from '@/services/portfolio.service'; +import { portfolioService } from '@/services/portfolio.service'; +import type { Portfolio } from '@/services/portfolio.service'; import '@/styles/components/portfolio.css'; export const PortfolioView: React.FC = () => { From 07fa82fc601ff23f8a45e07e4a008192eed453b4 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 21:31:39 +0530 Subject: [PATCH 03/11] fix(frontend): redirect successful logins to dashboard instead of explore --- src/App.tsx | 2 +- src/components/AuthCallback.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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); } From 96fc2ea382117086e0b0336ae86cc0c4047fbdd4 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 21:35:39 +0530 Subject: [PATCH 04/11] style(frontend): align dashboard layout and portfolio view colors to landing page design tokens --- src/features/dashboard/DashboardPage.tsx | 240 ++++++++++++++--------- src/styles/components/portfolio.css | 29 ++- 2 files changed, 164 insertions(+), 105 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index d6e52c4..e215ffd 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -13,114 +13,174 @@ interface DashboardPageProps { */ export const DashboardPage: React.FC = ({ user, onLogout }) => { return ( -
-
-

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

-

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

- -
- {/* Profile Card */} - {user && ( +
+ {/* Brand Header */} +
+

-
-

- Profile Details -

-

- Email: {user.email} -

-

- Role: {user.role} -

-

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

-

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

-
+ TRADING COPILOT +

+
- +

+ User: {user.username} +

+

+ Email: {user.email} +

+

+ Role: {user.role} +

+

+ Verified:{' '} + {user.is_verified ? 'Yes' : 'No'} +

+
)} +
- {/* Portfolio Management Card */} -
- -
+ {/* Logout Button */} + + + + {/* Main Content Area */} +
+
+

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

+

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

+ +
-
-
+ + ); }; diff --git a/src/styles/components/portfolio.css b/src/styles/components/portfolio.css index 864cc94..c2bcb25 100644 --- a/src/styles/components/portfolio.css +++ b/src/styles/components/portfolio.css @@ -1,10 +1,10 @@ /* Portfolio View Styling - Sleek Glassmorphism Design */ .portfolio-card { - background: rgba(17, 24, 39, 0.7); + background: var(--color-bg-secondary); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--color-border-subtle); border-radius: 16px; padding: 24px; margin-top: 24px; @@ -25,9 +25,7 @@ .portfolio-title { font-size: 1.5rem; font-weight: 700; - background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; + color: var(--color-text-primary); margin: 0; } @@ -62,7 +60,7 @@ height: 100%; border-radius: 9999px; transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); - background: linear-gradient(90deg, #3b82f6, #10b981); + background: var(--color-brand-teal); } .capacity-bar-fill.warning { @@ -89,13 +87,13 @@ } .portfolio-input:focus { - border-color: #3b82f6; + border-color: var(--color-brand-teal); background: rgba(255, 255, 255, 0.06); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); + box-shadow: 0 0 0 2px rgba(13, 148, 136, 0.2); } .portfolio-btn { - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + background: var(--color-brand-teal); color: #ffffff; border: none; border-radius: 8px; @@ -103,12 +101,13 @@ font-weight: 600; cursor: pointer; transition: all 0.25s ease; - box-shadow: 0 4px 12px rgba(37, 99, 235, 0.2); + box-shadow: 0 4px 12px rgba(13, 148, 136, 0.2); } .portfolio-btn:hover:not(:disabled) { transform: translateY(-1px); - box-shadow: 0 6px 16px rgba(37, 99, 235, 0.35); + background-color: var(--color-brand-teal-hover); + box-shadow: 0 6px 16px rgba(13, 148, 136, 0.35); } .portfolio-btn:active:not(:disabled) { @@ -143,7 +142,7 @@ .holding-item:hover { background: rgba(255, 255, 255, 0.04); - border-color: rgba(59, 130, 246, 0.2); + border-color: var(--color-brand-teal); transform: translateX(4px); } @@ -154,9 +153,9 @@ } .holding-avatar { - background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(37, 99, 235, 0.2) 100%); - border: 1px solid rgba(59, 130, 246, 0.3); - color: #60a5fa; + 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; From 5bc5e462aa4818baf36f674e2784f82031ce1c33 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 21:43:08 +0530 Subject: [PATCH 05/11] feat(frontend): show live rates next to portfolio stocks & fix body scroll overflow --- src/components/PortfolioView.tsx | 67 +++++++++ src/index.css | 238 ++++++++++++++++++++++++------ src/services/portfolio.service.ts | 3 + 3 files changed, 263 insertions(+), 45 deletions(-) diff --git a/src/components/PortfolioView.tsx b/src/components/PortfolioView.tsx index 64337df..90469ef 100644 --- a/src/components/PortfolioView.tsx +++ b/src/components/PortfolioView.tsx @@ -88,6 +88,14 @@ export const PortfolioView: React.FC = () => { 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 && ( @@ -194,6 +202,65 @@ export const PortfolioView: React.FC = () => {
+ + {/* 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)}% +
+ )} +
+ )} + - ))} - - - -
- {/* Gainers */} -
-
-

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

-
+ return () => ws.close(); + }, []); + + // 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); + } + }; -
- {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...
- )} + const formatPrice = (price: number) => { + if (!price) return '0.00'; + if (price < 0.01) { + return price.toExponential(5); + } + return price.toFixed(2); + }; + + const getInitials = (symbol: string) => { + return symbol.substring(0, 2).toUpperCase(); + }; + + return ( +
+ {/* Dynamic Feedback Banner */} + {feedback && ( +
+ {feedback.type === 'success' ? '✅ ' : '⚠️ '} + {feedback.message} +
+ )} + +
+
+ {['Stocks', 'Crypto', 'Futures', 'Forex', 'Economy', 'Brokers'].map((tab) => ( + + ))} +
+
+ +
+ {/* Gainers */} +
+
+

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

+
+ +
+ {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)}%
+ + {isAuthenticated && ( + + )} +
+ )) + ) : ( +
Waiting for data...
+ )} +
+
- {/* Losers */} -
-
-

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

-
+ {/* Losers */} +
+
+

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

+
-
- {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...
- )} +
+ {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)}%
+ + {isAuthenticated && ( + + )} +
-
+ )) + ) : ( +
Waiting for data...
+ )} +
- ); +
+
+ ); }; From 5bfc629d28ee156be5ffdbd9a42a54464e6a99dc Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 21:56:19 +0530 Subject: [PATCH 07/11] feat(frontend): add navigation buttons between Dashboard and Explore Market pages --- src/features/dashboard/DashboardPage.tsx | 69 ++++++++++++++++++++++++ src/pages/LiveMarketDashboard.tsx | 51 +++++++++++++++++- 2 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index e215ffd..9ba9af7 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -1,6 +1,8 @@ import React from 'react'; +import { useNavigate } from 'react-router-dom'; import type { UserResponse } from '@/features/auth/auth.types'; import { PortfolioView } from '@/components/PortfolioView'; +import { ROUTES } from '@/constants/routes.constants'; import '@/styles/components/dashboard.css'; interface DashboardPageProps { @@ -12,6 +14,8 @@ interface DashboardPageProps { * DashboardPage — Post-login dashboard landing. */ export const DashboardPage: React.FC = ({ user, onLogout }) => { + const navigate = useNavigate(); + return (
= ({ user, onLogout })
)} + + {/* Navigation Links */} +
+
+ Navigation +
+ + + + +
{/* Logout Button */} diff --git a/src/pages/LiveMarketDashboard.tsx b/src/pages/LiveMarketDashboard.tsx index 92d5bef..2181fc1 100644 --- a/src/pages/LiveMarketDashboard.tsx +++ b/src/pages/LiveMarketDashboard.tsx @@ -1,11 +1,14 @@ import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useUserStore } from '@/stores/userStore'; import { portfolioService } from '@/services/portfolio.service'; +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 navigate = useNavigate(); const [marketData, setMarketData] = useState({ gainers: [], losers: [] }); const [activeTab, setActiveTab] = useState('Stocks'); const [portfolioHoldings, setPortfolioHoldings] = useState([]); @@ -116,8 +119,22 @@ export const LiveMarketDashboard: React.FC = () => {
)} -
-
+
+ {/* Left spacing block for balance */} +
+ + {/* Navigation Pills */} +
{['Stocks', 'Crypto', 'Futures', 'Forex', 'Economy', 'Brokers'].map((tab) => ( ))}
+ + {/* Right Dashboard navigation button */} +
+ {isAuthenticated && ( + + )} +
From 0177440a67f2db079bad8c03122da07d630d2918 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 22:23:25 +0530 Subject: [PATCH 08/11] feat(frontend): extract Sidebar to reusable component and preserve sidebar on explore page for authenticated users --- src/components/Sidebar.tsx | 254 +++++++++++++++++++++++ src/features/dashboard/DashboardPage.tsx | 216 +------------------ src/pages/LiveMarketDashboard.tsx | 85 ++++---- 3 files changed, 302 insertions(+), 253 deletions(-) create mode 100644 src/components/Sidebar.tsx 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 9ba9af7..c7fb42c 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -1,8 +1,7 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; import type { UserResponse } from '@/features/auth/auth.types'; import { PortfolioView } from '@/components/PortfolioView'; -import { ROUTES } from '@/constants/routes.constants'; +import { Sidebar } from '@/components/Sidebar'; import '@/styles/components/dashboard.css'; interface DashboardPageProps { @@ -14,8 +13,6 @@ interface DashboardPageProps { * DashboardPage — Post-login dashboard landing. */ export const DashboardPage: React.FC = ({ user, onLogout }) => { - const navigate = useNavigate(); - return (
= ({ user, onLogout }) }} > {/* Sidebar Panel */} - + {/* Main Content Area */}
diff --git a/src/pages/LiveMarketDashboard.tsx b/src/pages/LiveMarketDashboard.tsx index 2181fc1..d90f8f0 100644 --- a/src/pages/LiveMarketDashboard.tsx +++ b/src/pages/LiveMarketDashboard.tsx @@ -2,6 +2,7 @@ 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'; @@ -16,7 +17,12 @@ export const LiveMarketDashboard: React.FC = () => { null, ); - const isAuthenticated = useUserStore((s) => s.isAuthenticated); + const { user, logout, isAuthenticated } = useUserStore(); + + const handleLogout = async () => { + await logout(); + navigate(ROUTES.HOME); + }; // 1. Listen to market data WebSocket useEffect(() => { @@ -90,7 +96,7 @@ export const LiveMarketDashboard: React.FC = () => { return symbol.substring(0, 2).toUpperCase(); }; - return ( + const dashboardContent = (
{/* Dynamic Feedback Banner */} {feedback && ( @@ -130,10 +136,8 @@ export const LiveMarketDashboard: React.FC = () => { margin: '0 auto 40px auto', }} > - {/* Left spacing block for balance */}
- {/* Navigation Pills */}
{['Stocks', 'Crypto', 'Futures', 'Forex', 'Economy', 'Brokers'].map((tab) => (
- {/* Right Dashboard navigation button */}
{isAuthenticated && (
- -
+
{marketData.gainers && marketData.gainers.length > 0 ? ( marketData.gainers.map((stock: any) => ( -
-
-
{getInitials(stock.symbol)}
-
-
{stock.symbol.split('.')[0]}
+
+
+
{getInitials(stock.symbol)}
+
{stock.symbol}
-
- {formatPrice(stock.price)} - {stock.currency || 'USD'} -
-
-
+{Math.abs(stock.percent_change).toFixed(2)}%
- +
+ {formatPrice(stock.price)} + + +{formatPrice(stock.percent_change)}% + {isAuthenticated && (
- -
+
{marketData.losers && marketData.losers.length > 0 ? ( marketData.losers.map((stock: any) => ( -
-
-
{getInitials(stock.symbol)}
-
-
{stock.symbol.split('.')[0]}
+
+
+
{getInitials(stock.symbol)}
+
{stock.symbol}
-
- {formatPrice(stock.price)} - {stock.currency || 'USD'} -
-
-
-{Math.abs(stock.percent_change).toFixed(2)}%
- +
+ {formatPrice(stock.price)} + + {formatPrice(stock.percent_change)}% + {isAuthenticated && (
); + + if (isAuthenticated) { + return ( +
+ +
{dashboardContent}
+
+ ); + } + + return dashboardContent; }; From 4656a3008d93ec66b593db574a804cf511612509 Mon Sep 17 00:00:00 2001 From: Sreenand P K Date: Wed, 22 Jul 2026 22:26:23 +0530 Subject: [PATCH 09/11] feat(frontend): apply premium dark theme and add branding logo header to guest explore page --- src/pages/LiveMarketDashboard.tsx | 60 ++++++++++++ src/styles/components/live-market.css | 135 +++++++++++--------------- 2 files changed, 115 insertions(+), 80 deletions(-) diff --git a/src/pages/LiveMarketDashboard.tsx b/src/pages/LiveMarketDashboard.tsx index d90f8f0..0db0f0d 100644 --- a/src/pages/LiveMarketDashboard.tsx +++ b/src/pages/LiveMarketDashboard.tsx @@ -125,6 +125,66 @@ export const LiveMarketDashboard: React.FC = () => {
)} + {/* Guest Logo Header */} + {!isAuthenticated && ( +
+
+
+

+ TRADING COPILOT +

+
+ +
+ )} +
Date: Wed, 22 Jul 2026 22:30:04 +0530 Subject: [PATCH 10/11] fix(explore): format negative percent changes to 2 decimal places to avoid scientific notation --- src/pages/LiveMarketDashboard.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/LiveMarketDashboard.tsx b/src/pages/LiveMarketDashboard.tsx index 0db0f0d..aaf10ed 100644 --- a/src/pages/LiveMarketDashboard.tsx +++ b/src/pages/LiveMarketDashboard.tsx @@ -86,12 +86,17 @@ export const LiveMarketDashboard: React.FC = () => { const formatPrice = (price: number) => { if (!price) return '0.00'; - if (price < 0.01) { + if (Math.abs(price) < 0.01) { return price.toExponential(5); } return price.toFixed(2); }; + const formatPercent = (percent: number) => { + if (!percent) return '0.00'; + return percent.toFixed(2); + }; + const getInitials = (symbol: string) => { return symbol.substring(0, 2).toUpperCase(); }; @@ -262,7 +267,7 @@ export const LiveMarketDashboard: React.FC = () => {
{formatPrice(stock.price)} - +{formatPrice(stock.percent_change)}% + +{formatPercent(stock.percent_change)}% {isAuthenticated && (
- +
@@ -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 });