finished initial symbols pages

This commit is contained in:
Boki 2025-07-02 20:53:26 -04:00
parent 62706cdb42
commit 4e4a048988
13 changed files with 688 additions and 107 deletions

View file

@ -4,7 +4,7 @@ import { ExchangesPage } from '@/features/exchanges';
import { MonitoringPage } from '@/features/monitoring';
import { PipelinePage } from '@/features/pipeline';
import { BacktestPage } from '@/features/backtest';
import { ChartPage } from '@/features/charts';
import { SymbolsPage, SymbolDetailPage } from '@/features/symbols';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
export function App() {
@ -14,7 +14,10 @@ export function App() {
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="charts" element={<ChartPage />} />
<Route path="symbols">
<Route index element={<SymbolsPage />} />
<Route path=":symbol" element={<SymbolDetailPage />} />
</Route>
<Route path="exchanges" element={<ExchangesPage />} />
<Route
path="portfolio"

View file

@ -2,8 +2,8 @@ import { navigation } from '@/lib/constants';
import type { NavigationItem } from '@/lib/constants';
import { cn } from '@/lib/utils';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon, ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { Fragment, useState } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { Fragment } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
interface SidebarProps {
@ -78,29 +78,6 @@ export function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
function SidebarContent() {
const location = useLocation();
// Auto-expand items that have active children
const getInitialExpanded = () => {
const expanded = new Set<string>();
navigation.forEach(item => {
if (item.children && item.children.some(child => location.pathname === child.href)) {
expanded.add(item.name);
}
});
return expanded;
};
const [expandedItems, setExpandedItems] = useState<Set<string>>(getInitialExpanded());
const toggleExpanded = (name: string) => {
const newExpanded = new Set(expandedItems);
if (newExpanded.has(name)) {
newExpanded.delete(name);
} else {
newExpanded.add(name);
}
setExpandedItems(newExpanded);
};
const isChildActive = (children: NavigationItem[]) => {
return children.some(child => location.pathname === child.href);
@ -119,12 +96,11 @@ function SidebarContent() {
<li key={item.name}>
{item.children ? (
<>
<button
onClick={() => toggleExpanded(item.name)}
<div
className={cn(
isChildActive(item.children)
? 'bg-surface-secondary text-primary-400'
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
: 'text-text-secondary',
'group flex w-full gap-x-2 rounded-r-md px-2 py-1.5 text-sm leading-tight font-medium transition-colors'
)}
>
@ -132,52 +108,45 @@ function SidebarContent() {
className={cn(
isChildActive(item.children)
? 'text-primary-400'
: 'text-text-muted group-hover:text-primary-400',
: 'text-text-muted',
'h-4 w-4 shrink-0 transition-colors'
)}
aria-hidden="true"
/>
<span className="flex-1 text-left">{item.name}</span>
{expandedItems.has(item.name) ? (
<ChevronDownIcon className="h-3 w-3 text-text-muted" />
) : (
<ChevronRightIcon className="h-3 w-3 text-text-muted" />
)}
</button>
{expandedItems.has(item.name) && (
<ul className="mt-1 space-y-0.5 pl-8">
{item.children.map(child => (
<li key={child.name}>
<NavLink
to={child.href || ''}
className={({ isActive }) =>
cn(
isActive
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
'group flex gap-x-2 rounded-r-md px-2 py-1 text-sm leading-tight font-medium transition-colors'
)
}
>
{({ isActive }) => (
<>
<child.icon
className={cn(
isActive
? 'text-primary-400'
: 'text-text-muted group-hover:text-primary-400',
'h-3 w-3 shrink-0 transition-colors'
)}
aria-hidden="true"
/>
{child.name}
</>
)}
</NavLink>
</li>
))}
</ul>
)}
</div>
<ul className="mt-1 space-y-0.5 pl-5">
{item.children.map(child => (
<li key={child.name}>
<NavLink
to={child.href || ''}
className={({ isActive }) =>
cn(
isActive
? 'bg-surface-secondary text-primary-400 border-l-2 border-primary-500'
: 'text-text-secondary hover:text-primary-400 hover:bg-surface-secondary',
'group flex gap-x-2 rounded-r-md px-2 py-1 text-sm leading-tight font-medium transition-colors'
)
}
>
{({ isActive }) => (
<>
<child.icon
className={cn(
isActive
? 'text-primary-400'
: 'text-text-muted group-hover:text-primary-400',
'h-3 w-3 shrink-0 transition-colors'
)}
aria-hidden="true"
/>
{child.name}
</>
)}
</NavLink>
</li>
))}
</ul>
</>
) : (
<NavLink

View file

@ -42,9 +42,14 @@ function generateMockData(days: number = 365): CandlestickData[] {
return data;
}
export function ChartPage() {
interface ChartPageProps {
symbol?: string;
showHeader?: boolean;
}
export function ChartPage({ symbol = 'AAPL', showHeader = true }: ChartPageProps = {}) {
const [config, setConfig] = useState<ChartConfig>({
symbol: 'AAPL',
symbol,
interval: '1d',
chartType: 'candlestick',
showVolume: true,
@ -201,14 +206,16 @@ export function ChartPage() {
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0">
<h1 className="text-lg font-bold text-text-primary mb-2 px-8 pt-4">Market Charts</h1>
<p className="text-text-secondary mb-4 text-sm px-8">
Real-time market data and advanced charting powered by TradingView.
</p>
</div>
{showHeader && (
<div className="flex-shrink-0">
<h1 className="text-lg font-bold text-text-primary mb-2 px-8 pt-4">Market Charts</h1>
<p className="text-text-secondary mb-4 text-sm px-8">
Real-time market data and advanced charting powered by TradingView.
</p>
</div>
)}
<div className="flex-1 flex bg-surface-secondary rounded-lg border border-border mx-8 mb-8 overflow-hidden">
<div className={`flex-1 flex bg-surface-secondary ${showHeader ? 'rounded-lg border border-border mx-8 mb-8' : ''} overflow-hidden`}>
{/* Left Sidebar - Indicator List */}
{showSidebar && (
<IndicatorList

View file

@ -27,19 +27,8 @@ const chartTypes: { value: ChartType; label: string; icon?: typeof ChartBarIcon
export function ChartToolbar({ config, onConfigChange }: ChartToolbarProps) {
return (
<div className="flex items-center justify-between gap-4 p-4 bg-surface-secondary border-b border-border">
<div className="flex items-center gap-4">
{/* Symbol Input */}
<div className="flex items-center gap-2">
<input
type="text"
value={config.symbol}
onChange={(e) => onConfigChange({ symbol: e.target.value.toUpperCase() })}
placeholder="Symbol"
className="px-3 py-1.5 bg-background border border-border rounded-md text-sm font-medium text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500 w-24"
/>
</div>
<div className="flex items-center justify-between gap-2 bg-surface-secondary border-b border-border">
<div className="flex items-center gap-2">
{/* Interval Selector */}
<div className="flex items-center gap-1 bg-background rounded-md border border-border p-0.5">
{intervals.map(({ value, label }) => (
@ -76,19 +65,6 @@ export function ChartToolbar({ config, onConfigChange }: ChartToolbarProps) {
</div>
<div className="flex items-center gap-2">
{/* Volume Toggle */}
<button
onClick={() => onConfigChange({ showVolume: !config.showVolume })}
className={`px-3 py-1.5 text-xs font-medium rounded-md border transition-colors ${
config.showVolume
? 'bg-primary-500 text-white border-primary-500'
: 'bg-background text-text-secondary border-border hover:text-text-primary hover:border-primary-500/50'
}`}
>
Volume
</button>
{/* Theme Toggle */}
<button
onClick={() => onConfigChange({ theme: config.theme === 'dark' ? 'light' : 'dark' })}

View file

@ -0,0 +1,221 @@
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { ChartPage } from '../charts/ChartPage';
import type { Symbol } from './types';
const tabs = [
{ id: 'chart', name: 'Chart' },
{ id: 'financials', name: 'Financials' },
{ id: 'filings', name: 'Filings' },
{ id: 'insiders', name: 'Insiders' },
{ id: 'shorts', name: 'Shorts' },
{ id: 'sentiment', name: 'Sentiment' },
{ id: 'news', name: 'News' },
{ id: 'info', name: 'Info' },
];
// Mock symbol data - in real app would fetch from API
const mockSymbolData: Record<string, Symbol> = {
'AAPL': { symbol: 'AAPL', name: 'Apple Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 3.48e12, sector: 'Technology', industry: 'Consumer Electronics' },
'SHOP': { symbol: 'SHOP', name: 'Shopify Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 135e9, sector: 'Technology', industry: 'E-Commerce Software' },
'TD': { symbol: 'TD', name: 'Toronto-Dominion Bank', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 150e9, sector: 'Financial', industry: 'Banks' },
};
export function SymbolDetailPage() {
const { symbol } = useParams<{ symbol: string }>();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState('chart');
const [symbolData, setSymbolData] = useState<Symbol | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Simulate API call to fetch symbol data
setIsLoading(true);
setTimeout(() => {
const data = mockSymbolData[symbol || ''] || {
symbol: symbol || 'UNKNOWN',
name: 'Unknown Company',
exchange: 'UNKNOWN',
country: 'US',
type: 'Common Stock',
currency: 'USD',
};
setSymbolData(data);
setIsLoading(false);
}, 500);
}, [symbol]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-primary-500"></div>
</div>
);
}
if (!symbolData) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<p className="text-text-secondary mb-4">Symbol not found</p>
<button
onClick={() => navigate('/symbols')}
className="text-primary-500 hover:text-primary-600"
>
Back to Symbols
</button>
</div>
</div>
);
}
const renderTabContent = () => {
switch (activeTab) {
case 'chart':
return <ChartPage symbol={symbolData.symbol} showHeader={false} />;
case 'financials':
return (
<div className="p-8">
<h2 className="text-lg font-semibold text-text-primary mb-4">Financial Statements</h2>
<p className="text-text-secondary">Financial data coming soon...</p>
</div>
);
case 'filings':
return (
<div className="p-8">
<h2 className="text-lg font-semibold text-text-primary mb-4">SEC Filings</h2>
<p className="text-text-secondary">Filings data coming soon...</p>
</div>
);
case 'insiders':
return (
<div className="p-8">
<h2 className="text-lg font-semibold text-text-primary mb-4">Insider Trading</h2>
<p className="text-text-secondary">Insider trading data coming soon...</p>
</div>
);
case 'shorts':
return (
<div className="p-8">
<h2 className="text-lg font-semibold text-text-primary mb-4">Short Interest</h2>
<p className="text-text-secondary">Short interest data coming soon...</p>
</div>
);
case 'sentiment':
return (
<div className="p-8">
<h2 className="text-lg font-semibold text-text-primary mb-4">Market Sentiment</h2>
<p className="text-text-secondary">Sentiment analysis coming soon...</p>
</div>
);
case 'news':
return (
<div className="p-8">
<h2 className="text-lg font-semibold text-text-primary mb-4">Latest News</h2>
<p className="text-text-secondary">News feed coming soon...</p>
</div>
);
case 'info':
return (
<div className="p-8">
<h2 className="text-lg font-semibold text-text-primary mb-4">Company Information</h2>
<div className="bg-surface-secondary rounded-lg border border-border p-6">
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-text-secondary">Symbol</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.symbol}</dd>
</div>
<div>
<dt className="text-sm font-medium text-text-secondary">Name</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.name}</dd>
</div>
<div>
<dt className="text-sm font-medium text-text-secondary">Exchange</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.exchange}</dd>
</div>
<div>
<dt className="text-sm font-medium text-text-secondary">Country</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.country}</dd>
</div>
<div>
<dt className="text-sm font-medium text-text-secondary">Type</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.type}</dd>
</div>
<div>
<dt className="text-sm font-medium text-text-secondary">Currency</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.currency}</dd>
</div>
{symbolData.sector && (
<div>
<dt className="text-sm font-medium text-text-secondary">Sector</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.sector}</dd>
</div>
)}
{symbolData.industry && (
<div>
<dt className="text-sm font-medium text-text-secondary">Industry</dt>
<dd className="mt-1 text-sm text-text-primary">{symbolData.industry}</dd>
</div>
)}
</dl>
</div>
</div>
);
default:
return null;
}
};
return (
<div className="h-full flex flex-col">
{/* Header with Tabs */}
<div className="flex-shrink-0 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center">
<button
onClick={() => navigate('/symbols')}
className="p-2 hover:bg-surface-tertiary transition-colors"
aria-label="Back to symbols"
>
<ArrowLeftIcon className="h-5 w-5 text-text-secondary" />
</button>
<div className="px-2">
<h1 className="text-sm font-bold text-text-primary flex items-center gap-1">
{symbolData.symbol}
<span className="text-xs font-normal text-text-secondary">
{symbolData.exchange}
</span>
</h1>
<p className="text-xs text-text-secondary">{symbolData.name}</p>
</div>
{/* Tabs */}
<nav className="flex ml-4" aria-label="Tabs">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
whitespace-nowrap py-2 px-3 border-b-2 font-medium text-sm
${activeTab === tab.id
? 'border-primary-500 text-primary-500'
: 'border-transparent text-text-secondary hover:text-text-primary hover:border-border'
}
`}
>
{tab.name}
</button>
))}
</nav>
</div>
</div>
</div>
{/* Tab Content */}
<div className="flex-1 overflow-auto">
{renderTabContent()}
</div>
</div>
);
}

View file

@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { SymbolsFilter } from './components/SymbolsFilter';
import { SymbolsTable } from './components/SymbolsTable';
import type { Exchange, Symbol, SymbolFilter } from './types';
// Mock data for demonstration
const mockExchanges: Exchange[] = [
{ code: 'TSX', name: 'Toronto Stock Exchange', country: 'CA' },
{ code: 'TSXV', name: 'TSX Venture Exchange', country: 'CA' },
{ code: 'NYSE', name: 'New York Stock Exchange', country: 'US' },
{ code: 'NASDAQ', name: 'NASDAQ', country: 'US' },
{ code: 'AMEX', name: 'NYSE American', country: 'US' },
];
const mockSymbols: Symbol[] = [
{ symbol: 'AAPL', name: 'Apple Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 3.48e12, sector: 'Technology', industry: 'Consumer Electronics' },
{ symbol: 'MSFT', name: 'Microsoft Corporation', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 3.18e12, sector: 'Technology', industry: 'Software' },
{ symbol: 'GOOGL', name: 'Alphabet Inc. Class A', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 2.23e12, sector: 'Technology', industry: 'Internet Services' },
{ symbol: 'AMZN', name: 'Amazon.com Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 2.16e12, sector: 'Consumer Cyclical', industry: 'E-Commerce' },
{ symbol: 'TSLA', name: 'Tesla Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 1.25e12, sector: 'Consumer Cyclical', industry: 'Auto Manufacturers' },
{ symbol: 'META', name: 'Meta Platforms Inc.', exchange: 'NASDAQ', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 1.52e12, sector: 'Technology', industry: 'Social Media' },
{ symbol: 'JPM', name: 'JPMorgan Chase & Co.', exchange: 'NYSE', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 650e9, sector: 'Financial', industry: 'Banks' },
{ symbol: 'BAC', name: 'Bank of America Corp.', exchange: 'NYSE', country: 'US', type: 'Common Stock', currency: 'USD', marketCap: 370e9, sector: 'Financial', industry: 'Banks' },
{ symbol: 'TD', name: 'Toronto-Dominion Bank', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 150e9, sector: 'Financial', industry: 'Banks' },
{ symbol: 'RY', name: 'Royal Bank of Canada', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 200e9, sector: 'Financial', industry: 'Banks' },
{ symbol: 'SHOP', name: 'Shopify Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 135e9, sector: 'Technology', industry: 'E-Commerce Software' },
{ symbol: 'CP', name: 'Canadian Pacific Kansas City', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 110e9, sector: 'Industrials', industry: 'Railroads' },
{ symbol: 'CNR', name: 'Canadian National Railway', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 105e9, sector: 'Industrials', industry: 'Railroads' },
{ symbol: 'BMO', name: 'Bank of Montreal', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 125e9, sector: 'Financial', industry: 'Banks' },
{ symbol: 'BNS', name: 'Bank of Nova Scotia', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 95e9, sector: 'Financial', industry: 'Banks' },
{ symbol: 'ENB', name: 'Enbridge Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 110e9, sector: 'Energy', industry: 'Oil & Gas Pipelines' },
{ symbol: 'SU', name: 'Suncor Energy Inc.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 65e9, sector: 'Energy', industry: 'Oil & Gas' },
{ symbol: 'TRP', name: 'TC Energy Corporation', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 60e9, sector: 'Energy', industry: 'Oil & Gas Pipelines' },
{ symbol: 'MFC', name: 'Manulife Financial Corp.', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 55e9, sector: 'Financial', industry: 'Insurance' },
{ symbol: 'BAM', name: 'Brookfield Asset Management', exchange: 'TSX', country: 'CA', type: 'Common Stock', currency: 'CAD', marketCap: 85e9, sector: 'Financial', industry: 'Asset Management' },
];
export function SymbolsPage() {
const [filters, setFilters] = useState<SymbolFilter>({
country: ['CA', 'US'],
});
const [filteredSymbols, setFilteredSymbols] = useState<Symbol[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Simulate API call
setIsLoading(true);
setTimeout(() => {
let symbols = [...mockSymbols];
// Apply filters
if (filters.exchange) {
symbols = symbols.filter(s => s.exchange === filters.exchange);
}
if (filters.searchText) {
const search = filters.searchText.toLowerCase();
symbols = symbols.filter(s =>
s.symbol.toLowerCase().includes(search) ||
s.name.toLowerCase().includes(search)
);
}
if (filters.country && filters.country.length > 0) {
symbols = symbols.filter(s => filters.country!.includes(s.country));
}
// Sort by market cap (descending)
symbols.sort((a, b) => (b.marketCap || 0) - (a.marketCap || 0));
setFilteredSymbols(symbols);
setIsLoading(false);
}, 500);
}, [filters]);
return (
<div className="h-full flex flex-col">
<div className="flex-shrink-0 border-b border-border">
<div className="flex items-center gap-4 px-3 py-2">
<h1 className="text-lg font-bold text-text-primary">Symbols</h1>
<SymbolsFilter
filters={filters}
exchanges={mockExchanges}
onFiltersChange={setFilters}
/>
<div className="text-sm text-text-secondary ml-auto">
{isLoading ? (
'Loading...'
) : (
`${filteredSymbols.length} found`
)}
</div>
</div>
</div>
<div className="flex-1 w-full">
<SymbolsTable symbols={filteredSymbols} isLoading={isLoading} />
</div>
</div>
);
}

View file

@ -0,0 +1,92 @@
import { Fragment, useState } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline';
interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
options: Option[];
selected: string[];
onChange: (selected: string[]) => void;
placeholder?: string;
className?: string;
}
export function MultiSelect({ options, selected, onChange, placeholder = 'Select...', className = '' }: MultiSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const handleToggle = (value: string) => {
if (selected.includes(value)) {
onChange(selected.filter(v => v !== value));
} else {
onChange([...selected, value]);
}
};
const selectedLabels = selected.map(value =>
options.find(opt => opt.value === value)?.label || value
).join(', ');
return (
<div className={`relative ${className}`}>
<Listbox value={selected} onChange={() => {}} multiple>
<div className="relative">
<Listbox.Button
onClick={() => setIsOpen(!isOpen)}
className="relative w-full cursor-default rounded-md bg-background border border-border py-1 pl-2 pr-8 text-left text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
>
<span className={`block truncate ${selected.length === 0 ? 'text-text-secondary' : ''}`}>
{selected.length > 0 ? selectedLabels : placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-text-secondary" aria-hidden="true" />
</span>
</Listbox.Button>
<Transition
show={isOpen}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Listbox.Options
static
className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-background border border-border py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
{options.map((option) => (
<Listbox.Option
key={option.value}
value={option.value}
disabled={false}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${
active ? 'bg-surface-secondary text-text-primary' : 'text-text-primary'
}`
}
onClick={(e) => {
e.preventDefault();
handleToggle(option.value);
}}
>
<>
<span className={`block truncate ${selected.includes(option.value) ? 'font-medium' : 'font-normal'}`}>
{option.label}
</span>
{selected.includes(option.value) ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-primary-500">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
</div>
);
}

View file

@ -0,0 +1,80 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import type { Exchange, SymbolFilter } from '../types';
import { MultiSelect } from './MultiSelect';
interface SymbolsFilterProps {
filters: SymbolFilter;
exchanges: Exchange[];
onFiltersChange: (filters: SymbolFilter) => void;
}
export function SymbolsFilter({ filters, exchanges, onFiltersChange }: SymbolsFilterProps) {
const handleExchangeChange = (exchange: string) => {
onFiltersChange({
...filters,
exchange: exchange === 'all' ? undefined : exchange,
});
};
const handleSearchChange = (searchText: string) => {
onFiltersChange({
...filters,
searchText: searchText.trim() || undefined,
});
};
const handleCountryChange = (countries: string[]) => {
onFiltersChange({
...filters,
country: countries.length > 0 ? countries : undefined,
});
};
const countryOptions = [
{ value: 'CA', label: 'Canada' },
{ value: 'US', label: 'United States' },
];
return (
<div className="flex items-center gap-2 flex-1">
{/* Exchange Dropdown */}
<select
value={filters.exchange || 'all'}
onChange={(e) => handleExchangeChange(e.target.value)}
className="rounded-md bg-background border border-border px-2 py-1 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
>
<option value="all">All Exchanges</option>
{exchanges.map((exchange) => (
<option key={exchange.code} value={exchange.code}>
{exchange.code}
</option>
))}
</select>
{/* Symbol Search */}
<div className="relative flex-1 max-w-xs">
<div className="absolute inset-y-0 left-0 pl-2 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-3.5 w-3.5 text-text-secondary" />
</div>
<input
type="text"
placeholder="Search..."
value={filters.searchText || ''}
onChange={(e) => handleSearchChange(e.target.value)}
className="w-full pl-8 pr-2 py-1 rounded-md bg-background border border-border text-sm text-text-primary placeholder-text-secondary focus:outline-none focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Country Filter */}
<div className="w-40">
<MultiSelect
options={countryOptions}
selected={filters.country || ['CA', 'US']}
onChange={handleCountryChange}
placeholder="Countries"
className="text-sm"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
import { useNavigate } from 'react-router-dom';
import type { Symbol } from '../types';
import { DataTable } from '@/components/ui/DataTable';
import type { ColumnDef } from '@tanstack/react-table';
interface SymbolsTableProps {
symbols: Symbol[];
isLoading?: boolean;
}
export function SymbolsTable({ symbols, isLoading }: SymbolsTableProps) {
const navigate = useNavigate();
const formatMarketCap = (value?: number) => {
if (!value) return '-';
if (value >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
if (value >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
if (value >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
return `$${value.toLocaleString()}`;
};
const columns: ColumnDef<Symbol>[] = [
{
accessorKey: 'symbol',
header: 'Symbol',
size: 120,
cell: ({ row }) => (
<div>
<div className="text-sm font-medium text-text-primary">{row.original.symbol}</div>
<div className="text-xs text-text-secondary">{row.original.currency}</div>
</div>
),
},
{
accessorKey: 'name',
header: 'Name',
size: 250,
cell: ({ getValue }) => (
<div className="text-sm text-text-primary">{getValue() as string}</div>
),
},
{
accessorKey: 'exchange',
header: 'Exchange',
size: 150,
cell: ({ row }) => (
<div>
<div className="text-sm text-text-primary">{row.original.exchange}</div>
<div className="text-xs text-text-secondary">{row.original.country}</div>
</div>
),
},
{
accessorKey: 'type',
header: 'Type',
size: 120,
cell: ({ getValue }) => (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-primary-100 text-primary-800">
{getValue() as string}
</span>
),
},
{
accessorKey: 'marketCap',
header: 'Market Cap',
size: 120,
cell: ({ getValue }) => (
<span className="text-sm text-text-primary">{formatMarketCap(getValue() as number)}</span>
),
},
{
accessorKey: 'sector',
header: 'Sector',
size: 200,
cell: ({ row }) => (
<div>
<div className="text-sm text-text-primary">{row.original.sector || '-'}</div>
<div className="text-xs text-text-secondary">{row.original.industry || '-'}</div>
</div>
),
},
];
if (symbols.length === 0 && !isLoading) {
return (
<div className="bg-surface-secondary border-t border-border p-8">
<div className="text-center text-text-secondary">
No symbols found matching your criteria
</div>
</div>
);
}
return (
<DataTable
data={symbols}
columns={columns}
loading={isLoading}
onRowClick={(symbol) => navigate(`/symbols/${symbol.symbol}`)}
className="border-0 rounded-none border-t h-full"
/>
);
}

View file

@ -0,0 +1,3 @@
export { SymbolsFilter } from './SymbolsFilter';
export { SymbolsTable } from './SymbolsTable';
export { MultiSelect } from './MultiSelect';

View file

@ -0,0 +1,3 @@
export { SymbolsPage } from './SymbolsPage';
export { SymbolDetailPage } from './SymbolDetailPage';
export * from './types';

View file

@ -0,0 +1,23 @@
export interface Symbol {
symbol: string;
name: string;
exchange: string;
country: string;
type: string;
currency: string;
marketCap?: number;
sector?: string;
industry?: string;
}
export interface SymbolFilter {
exchange?: string;
searchText?: string;
country?: string[];
}
export interface Exchange {
code: string;
name: string;
country: string;
}

View file

@ -25,8 +25,8 @@ export const navigation: NavigationItem[] = [
name: 'Market Data',
icon: CurrencyDollarIcon,
children: [
{ name: 'Charts', href: '/charts', icon: ChartBarIcon },
{ name: 'Exchanges', href: '/exchanges', icon: BuildingLibraryIcon },
{ name: 'Symbols', href: '/symbols', icon: ChartBarIcon },
],
},
{ name: 'Portfolio', href: '/portfolio', icon: ChartBarIcon },