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

@ -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;
}