|
|
| '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> |
| ) |
| } |
|
|