borsa / nextjs-app /src /components /StockList.tsx
veteroner's picture
feat: sync US market support — bootstrap fix, symbol validation, ghost cleanup, correct dir paths
cb11e81
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { StockSummary } from '@/types'
import { formatCurrency, formatPercent } from '@/lib/utils'
import { Star, TrendingUp, TrendingDown } from 'lucide-react'
import { fetchJson } from '@/lib/http'
import { useAuth } from '@/contexts/AuthContext'
import { loadFavoritesAsync, toggleFavoriteAsync } from '@/lib/favorites'
import { toNumber } from '@/lib/api-utils'
type StockRow = Partial<StockSummary> & {
id?: string | number
symbol: string
name?: string
sector?: string | null
current_price?: number | null
rsi?: number | null
trend?: string | null
}
type StockRowWithFav = StockRow & {
__isFav: boolean
}
function normalizeTrend(value: unknown): 'bullish' | 'bearish' | 'neutral' | null {
if (!value) return null
const v = String(value).toLowerCase().trim()
if (v === 'bullish' || v === 'up' || v === 'yukselis' || v === 'yükseliş') return 'bullish'
if (v === 'bearish' || v === 'down' || v === 'dusis' || v === 'düşüş') return 'bearish'
if (v === 'neutral' || v === 'flat' || v === 'yatay') return 'neutral'
return null
}
export function StockList() {
const { user } = useAuth()
const [stocks, setStocks] = useState<StockRow[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
const [favoriteSymbols, setFavoriteSymbols] = useState<Set<string>>(new Set())
const userKey = user?.id
useEffect(() => {
loadFavoritesAsync(userKey).then(favs => {
setFavoriteSymbols(new Set(favs.map((f) => f.symbol)))
})
}, [userKey])
useEffect(() => {
async function fetchStocks() {
try {
const data = await fetchJson<Record<string, unknown>>(`/api/stocks`, { method: 'GET' }, { timeoutMs: 45000, retries: 2, retryDelayMs: 2000 })
setStocks(Array.isArray(data) ? data : [])
} catch (error) {
console.error('Failed to fetch stocks:', error)
setStocks([])
} finally {
setLoading(false)
}
}
fetchStocks()
}, [])
const filteredStocks = (stocks || []).filter(stock =>
stock.symbol.toLowerCase().includes(filter.toLowerCase()) ||
String(stock.name || stock.symbol).toLowerCase().includes(filter.toLowerCase())
)
const filteredWithFav = useMemo(() => {
return filteredStocks.map((s) => ({ ...s, __isFav: favoriteSymbols.has(String(s.symbol).toUpperCase()) })) as StockRowWithFav[]
}, [filteredStocks, favoriteSymbols])
if (loading) {
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b">
<div className="h-8 bg-gray-200 rounded w-1/4 animate-pulse"></div>
</div>
<div className="p-6 space-y-4">
{[...Array(10)].map((_, i) => (
<div key={i} className="h-16 bg-gray-100 rounded animate-pulse"></div>
))}
</div>
</div>
)
}
return (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Hisse Senetleri</h2>
<input
type="text"
placeholder="Hisse ara (sembol veya isim)..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
/>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Sembol
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Şirket
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Fiyat
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Değişim
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Trend
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
RSI
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredWithFav.map((stock) => (
<tr key={stock.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-2">
<button
type="button"
onClick={async () => {
const { next } = await toggleFavoriteAsync(stock.symbol, userKey)
setFavoriteSymbols(new Set(next.map((f) => f.symbol)))
}}
className="p-1 rounded hover:bg-gray-100"
aria-label={stock.__isFav ? 'Favorilerden çıkar' : 'Favorilere ekle'}
title={stock.__isFav ? 'Favorilerden çıkar' : 'Favorilere ekle'}
>
<Star
className={
stock.__isFav
? 'h-4 w-4 text-amber-500 fill-amber-500'
: 'h-4 w-4 text-gray-300'
}
/>
</button>
<Link
href={`/stocks/${stock.symbol}`}
className="text-primary-600 font-semibold hover:text-primary-800"
>
{stock.symbol}
</Link>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">{stock.name}</div>
<div className="text-xs text-gray-500">{stock.sector}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="text-sm font-medium text-gray-900">
{toNumber(stock.last_price) !== null
? formatCurrency(Number(stock.last_price))
: toNumber(stock.current_price) !== null
? formatCurrency(Number(stock.current_price))
: '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
{stock.change_pct !== null && stock.change_pct !== undefined ? (
<div className="flex items-center justify-end space-x-1">
{stock.change_pct > 0 ? (
<>
<TrendingUp className="h-4 w-4 text-success" />
<span className="text-sm font-medium text-success">
{formatPercent(stock.change_pct)}
</span>
</>
) : stock.change_pct < 0 ? (
<>
<TrendingDown className="h-4 w-4 text-danger" />
<span className="text-sm font-medium text-danger">
{formatPercent(stock.change_pct)}
</span>
</>
) : (
<span className="text-sm text-gray-500">0.00%</span>
)}
</div>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
{normalizeTrend(stock.trend) ? (
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
normalizeTrend(stock.trend) === 'bullish'
? 'bg-green-100 text-green-800'
: normalizeTrend(stock.trend) === 'bearish'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
}`}>
{normalizeTrend(stock.trend) === 'bullish'
? 'Yükseliş'
: normalizeTrend(stock.trend) === 'bearish'
? 'Düşüş'
: 'Nötr'}
</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-center">
{toNumber(stock.rsi_14) !== null || toNumber(stock.rsi) !== null ? (
<span className={`text-sm font-medium ${
(toNumber(stock.rsi_14) ?? toNumber(stock.rsi) ?? 0) > 70
? 'text-red-600'
: (toNumber(stock.rsi_14) ?? toNumber(stock.rsi) ?? 0) < 30
? 'text-green-600'
: 'text-gray-600'
}`}>
{(toNumber(stock.rsi_14) ?? toNumber(stock.rsi) ?? 0).toFixed(1)}
</span>
) : (
<span className="text-sm text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredStocks.length === 0 && (
<div className="p-12 text-center text-gray-500">
Hiç hisse bulunamadı
</div>
)}
</div>
)
}