214 lines
8.8 KiB
TypeScript
214 lines
8.8 KiB
TypeScript
'use client'
|
|
|
|
import Link from 'next/link'
|
|
import { useSearchParams } from 'next/navigation'
|
|
import { ArrowLeft, ChevronDown, RefreshCw, BellPlus } from 'lucide-react'
|
|
import { Suspense, useState, useEffect } from 'react'
|
|
import { SentimentalityBar } from '@/components/widgets/SentimentalityBar'
|
|
import { getLiveQuote, formatMarketValue, type MappedQuote } from '@/lib/stockApi'
|
|
import { StockChart } from '@/components/stock-chart/stock-chart'
|
|
import { NewsHeadlines } from '@/components/widgets/NewsHeadlines'
|
|
import { AlertsModal } from '@/components/widgets/AlertsModal'
|
|
import type { TimeRange } from '@/components/stock-chart/types'
|
|
|
|
function SymbolDetailsContent() {
|
|
const searchParams = useSearchParams()
|
|
const symbol = (searchParams.get('symbol') || 'AAPL').toUpperCase()
|
|
const [chartExpanded, setChartExpanded] = useState(true)
|
|
const [headlinesExpanded, setHeadlinesExpanded] = useState(true)
|
|
const [selectedRange, setSelectedRange] = useState<TimeRange>('1D')
|
|
const [quote, setQuote] = useState<MappedQuote | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [refreshTick, setRefreshTick] = useState(0)
|
|
const [avgSentiment, setAvgSentiment] = useState<number | null>(null)
|
|
const [alertsModalOpen, setAlertsModalOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setLoading(true)
|
|
setError(null)
|
|
getLiveQuote(symbol)
|
|
.then(setQuote)
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false))
|
|
}, [symbol, refreshTick])
|
|
|
|
return (
|
|
<div className="h-full flex flex-col">
|
|
{/* Header */}
|
|
<div className="bg-background border-b border-border px-6 py-4 flex items-center gap-4">
|
|
<Link href="/interests" className="p-2 hover:bg-secondary transition-colors">
|
|
<ArrowLeft size={20} />
|
|
</Link>
|
|
<h1 className="text-2xl font-bold">{symbol}</h1>
|
|
</div>
|
|
|
|
{/* Loading / Error states */}
|
|
{loading && (
|
|
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
|
Loading {symbol}...
|
|
</div>
|
|
)}
|
|
{!loading && error && (
|
|
<div className="flex-1 flex items-center justify-center text-negative">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && quote && (
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="p-6 space-y-6 max-w-5xl">
|
|
{/* Stock Summary Card */}
|
|
<div className="border border-border bg-card p-6 space-y-6">
|
|
{/* Card header row with refresh button */}
|
|
<div className="flex items-start justify-between pb-4 border-b border-border space-y-3">
|
|
<div className="space-y-3 flex-1">
|
|
<div className="text-lg font-semibold">{quote.symbol}</div>
|
|
|
|
<div className="flex items-baseline gap-4">
|
|
<div className="text-4xl font-bold">${quote.lastPrice.toFixed(2)}</div>
|
|
<div className={
|
|
quote.priceChange > 0 ? 'text-positive'
|
|
: quote.priceChange < 0 ? 'text-negative'
|
|
: 'text-foreground'
|
|
}>
|
|
<div className="font-semibold">
|
|
{quote.priceChange > 0 ? '+' : ''}${quote.priceChange.toFixed(2)}
|
|
</div>
|
|
<div className="text-sm">
|
|
({quote.priceChange > 0 ? '+' : ''}{quote.percentChange.toFixed(2)}%)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setAlertsModalOpen(true)}
|
|
className="p-2 hover:bg-secondary transition-colors text-muted-foreground"
|
|
title="Configure alerts"
|
|
>
|
|
<BellPlus size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => setRefreshTick((t) => t + 1)}
|
|
disabled={loading}
|
|
className="p-2 hover:bg-secondary transition-colors disabled:opacity-40 text-muted-foreground"
|
|
title="Refresh quote"
|
|
>
|
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Key Metrics */}
|
|
<div className="grid grid-cols-2 gap-4 pb-4 border-b border-border">
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">Open</div>
|
|
<div className="font-semibold">
|
|
{formatMarketValue(quote.openPrice, quote.marketOpen, (v) => `$${v.toFixed(2)}`)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">Prev Close</div>
|
|
<div className="font-semibold">${quote.previousClose.toFixed(2)}</div>
|
|
</div>
|
|
<div className="space-y-2 col-span-2">
|
|
<div className="text-xs text-muted-foreground">Volume</div>
|
|
<div className="font-semibold">
|
|
{formatMarketValue(quote.volume, quote.marketOpen, (v) => v.toLocaleString())}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sentimentality Bar - average of headline sentiments */}
|
|
<SentimentalityBar sentiment={avgSentiment ?? 50} />
|
|
</div>
|
|
|
|
{/* Chart Section - Collapsible */}
|
|
<div className="border border-border">
|
|
<button
|
|
onClick={() => setChartExpanded(!chartExpanded)}
|
|
className="w-full flex items-center justify-between bg-card p-4 hover:bg-secondary transition-colors"
|
|
>
|
|
<span className="font-semibold">Chart</span>
|
|
<ChevronDown size={20} className={`transition-transform ${chartExpanded ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{chartExpanded && (
|
|
<div className="bg-card border-t border-border">
|
|
{/* Range selector */}
|
|
<div className="flex border-b border-border">
|
|
{(['1D', '5D', '1M', '3M', '6M', 'YTD', '1Y', '3Y'] as TimeRange[]).map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => setSelectedRange(r)}
|
|
className={`flex-1 py-2 text-xs font-medium transition-colors
|
|
${selectedRange === r
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'text-muted-foreground hover:text-foreground hover:bg-secondary'
|
|
}`}
|
|
>
|
|
{r}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Chart */}
|
|
<div className="p-4">
|
|
<StockChart symbol={symbol} range={selectedRange} />
|
|
</div>
|
|
|
|
<div className="px-4 pb-4">
|
|
<Link
|
|
href={`/complex-chart?symbol=${symbol}`}
|
|
className="block w-full text-center bg-primary text-primary-foreground p-3 hover:bg-blue-700 transition-colors font-medium"
|
|
>
|
|
Show more
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Headlines Section - Collapsible */}
|
|
<div className="border border-border">
|
|
<button
|
|
onClick={() => setHeadlinesExpanded(!headlinesExpanded)}
|
|
className="w-full flex items-center justify-between bg-card p-4 hover:bg-secondary transition-colors"
|
|
>
|
|
<span className="font-semibold">Headlines</span>
|
|
<ChevronDown size={20} className={`transition-transform ${headlinesExpanded ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
{headlinesExpanded && (
|
|
<div className="bg-card border-t border-border">
|
|
<div className="p-4">
|
|
<NewsHeadlines keyword={symbol} onAverageSentiment={setAvgSentiment} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<AlertsModal
|
|
open={alertsModalOpen}
|
|
onOpenChange={setAlertsModalOpen}
|
|
symbol={symbol}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function SymbolDetails() {
|
|
return (
|
|
<Suspense fallback={
|
|
<div className="h-full flex items-center justify-center bg-background">
|
|
<div className="text-muted-foreground">Loading...</div>
|
|
</div>
|
|
}>
|
|
<SymbolDetailsContent />
|
|
</Suspense>
|
|
)
|
|
}
|