DennisChan0909's picture
Backup current stock predictor strategies
ee37d63
Raw
History Blame Contribute Delete
12.1 kB
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Search, TrendingUp, Loader2, LogOut, Star, BarChart2, User, X, Gem, ScanLine, Droplets } from 'lucide-react'
import { searchStocks, SearchResult } from '../api/stockApi'
export type Market = 'tw' | 'us' | 'fund'
const MARKET_TABS: { key: Market; label: string; flag: string }[] = [
{ key: 'tw', label: '台肑', flag: 'πŸ‡ΉπŸ‡Ό' },
{ key: 'us', label: 'ηΎŽθ‚‘', flag: 'πŸ‡ΊπŸ‡Έ' },
{ key: 'fund', label: 'εŸΊι‡‘', flag: 'πŸ“Š' },
]
const MARKET_PLACEHOLDER: Record<Market, string> = {
tw: 'ε°θ‚‘δ»£θ™Ÿ (e.g. 7856)',
us: 'ηΎŽθ‚‘δ»£θ™Ÿ (e.g. AAPL)',
fund: 'εŸΊι‡‘δ»£θ™Ÿ (e.g. PIM91)',
}
interface HeaderProps {
input: string
setInput: (val: string) => void
months: number
setMonths: (val: number) => void
loading: boolean
onSubmit: (e: React.FormEvent) => void
onLogout: () => void
currentUser: string
userPhotoURL?: string
view: 'main' | 'favorites' | 'metals' | 'oil' | 'recommend'
onViewChange: (v: 'main' | 'favorites' | 'metals' | 'oil' | 'recommend') => void
favoritesCount: number
market: Market
onMarketChange: (m: Market) => void
onSelect: (stockNo: string) => void
}
export const Header: React.FC<HeaderProps> = ({
input, setInput,
months, setMonths,
loading,
onSubmit,
onLogout,
currentUser,
userPhotoURL,
view, onViewChange,
favoritesCount,
market, onMarketChange,
onSelect,
}) => {
const [suggestions, setSuggestions] = useState<SearchResult[]>([])
const [showDrop, setShowDrop] = useState(false)
const [searching, setSearching] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
// Close dropdown when clicking outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setShowDrop(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const fetchSuggestions = useCallback(async (q: string, mkt: Market) => {
if (!q.trim()) { setSuggestions([]); setShowDrop(false); return }
// For TW: need at least 1 char; for US/fund: 1 char
if (q.length < 1) return
setSearching(true)
try {
const results = await searchStocks(q, mkt)
setSuggestions(results)
setShowDrop(results.length > 0)
} catch {
setSuggestions([])
setShowDrop(false)
} finally {
setSearching(false)
}
}, [])
const handleInputChange = (val: string) => {
setInput(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => fetchSuggestions(val, market), 350)
}
const handleSelect = (stockNo: string) => {
setInput(stockNo)
setSuggestions([])
setShowDrop(false)
onSelect(stockNo)
}
const handleMarketChange = (m: Market) => {
onMarketChange(m)
setSuggestions([])
setShowDrop(false)
setInput('')
}
const handleFormSubmit = (e: React.FormEvent) => {
setShowDrop(false)
onSubmit(e)
}
const exchangeColor: Record<string, string> = {
TWSE: 'text-blue-400', TPEX: 'text-purple-400',
εŸΊι‡‘: 'text-yellow-400', MoneyDJ: 'text-yellow-400',
NYSE: 'text-green-400', NASDAQ: 'text-green-400',
}
return (
<header className="bg-slate-900 border-b border-slate-800 sticky top-0 z-20">
<div className="max-w-7xl mx-auto px-4 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3">
{/* Brand */}
<div className="flex items-center gap-2 mr-2 flex-shrink-0">
<TrendingUp className="text-blue-400" size={22} />
<h1 className="text-lg font-bold tracking-tight text-white whitespace-nowrap hidden sm:block">ML θ‚‘η₯¨ι ζΈ¬</h1>
</div>
{/* View nav (εˆ†ζž / ζœ€ζ„›) */}
<nav className="flex items-center gap-1 flex-shrink-0">
<button
onClick={() => onViewChange('main')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
view === 'main' ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<BarChart2 size={14} /> εˆ†ζž
</button>
<button
onClick={() => onViewChange('favorites')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
view === 'favorites' ? 'bg-yellow-500/20 text-yellow-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Star size={14} fill={view === 'favorites' ? 'currentColor' : 'none'} />
ζœ€ζ„›
{favoritesCount > 0 && (
<span className="bg-yellow-500 text-black text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
{favoritesCount > 9 ? '9+' : favoritesCount}
</span>
)}
</button>
<button
onClick={() => onViewChange('metals')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
view === 'metals' ? 'bg-yellow-500/20 text-yellow-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Gem size={14} />
貴金屬
</button>
<button
onClick={() => onViewChange('oil')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
view === 'oil' ? 'bg-cyan-500/20 text-cyan-300' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<Droplets size={14} />
石油
</button>
<button
onClick={() => onViewChange('recommend')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
view === 'recommend' ? 'bg-blue-600/20 text-blue-400' : 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<ScanLine size={14} />
Market Scan
</button>
</nav>
{/* Market tabs + search (only on main view) */}
{view === 'main' && (
<div className="flex items-center gap-2 flex-1">
{/* Market tabs */}
<div className="flex items-center bg-slate-800 rounded-lg p-0.5 flex-shrink-0">
{MARKET_TABS.map(tab => (
<button
key={tab.key}
onClick={() => handleMarketChange(tab.key)}
className={`flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium transition-colors ${
market === tab.key
? 'bg-slate-700 text-white shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
>
<span>{tab.flag}</span>
<span className="hidden sm:inline">{tab.label}</span>
</button>
))}
</div>
{/* Search with autocomplete */}
<form onSubmit={handleFormSubmit} className="flex items-center gap-2 flex-1">
<div className="relative flex-1 sm:flex-none" ref={wrapperRef}>
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none z-10" />
<input
type="text"
value={input}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => suggestions.length > 0 && setShowDrop(true)}
placeholder={MARKET_PLACEHOLDER[market]}
className="bg-slate-800 border border-slate-600 rounded-lg pl-8 pr-7 py-2 text-sm w-full sm:w-52 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 text-white placeholder-slate-500"
/>
{input && (
<button
type="button"
onClick={() => { setInput(''); setSuggestions([]); setShowDrop(false) }}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
>
<X size={12} />
</button>
)}
{/* Autocomplete dropdown */}
{showDrop && (
<div className="absolute top-full left-0 right-0 mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-50 max-h-64 overflow-y-auto">
{searching && (
<div className="px-3 py-2 text-xs text-slate-500 flex items-center gap-2">
<Loader2 size={12} className="animate-spin" /> ζœε°‹δΈ­...
</div>
)}
{suggestions.map((s) => (
<button
key={s.stock_no}
type="button"
onClick={() => handleSelect(s.stock_no)}
className="w-full px-3 py-2 text-left hover:bg-slate-700 flex items-center justify-between gap-2 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono font-bold text-white text-sm flex-shrink-0">{s.stock_no}</span>
<span className="text-slate-300 text-xs truncate">{s.name}</span>
</div>
<span className={`text-[10px] font-medium flex-shrink-0 ${exchangeColor[s.exchange] ?? 'text-slate-400'}`}>
{s.exchange}
</span>
</button>
))}
</div>
)}
</div>
<select
value={months}
onChange={(e) => setMonths(Number(e.target.value))}
className="bg-slate-800 border border-slate-600 rounded-lg px-2 py-2 text-sm focus:outline-none focus:border-blue-500 text-white flex-shrink-0"
>
<option value={3}>3ε€‹ζœˆ</option>
<option value={6}>6ε€‹ζœˆ</option>
<option value={12}>12ε€‹ζœˆ</option>
<option value={24}>24ε€‹ζœˆ</option>
</select>
<button
type="submit"
disabled={loading}
className="flex items-center gap-1.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:cursor-not-allowed text-white rounded-lg px-4 py-2 text-sm font-medium transition-colors flex-shrink-0"
>
{loading ? <Loader2 size={14} className="animate-spin" /> : <Search size={14} />}
<span className="hidden sm:inline">ζŸ₯θ©’</span>
</button>
</form>
</div>
)}
{/* User avatar + logout */}
<div className="flex items-center gap-2 sm:ml-auto flex-shrink-0">
{userPhotoURL ? (
<img src={userPhotoURL} alt={currentUser} className="w-7 h-7 rounded-full border border-slate-600 object-cover" referrerPolicy="no-referrer" />
) : (
<div className="w-7 h-7 rounded-full bg-slate-700 flex items-center justify-center">
<User size={14} className="text-slate-400" />
</div>
)}
<span className="hidden sm:inline text-xs font-medium text-slate-300 max-w-[100px] truncate">{currentUser}</span>
<button
onClick={onLogout}
className="p-2 text-slate-400 hover:text-white transition-colors rounded-lg hover:bg-slate-800"
title="η™»ε‡Ί"
type="button"
>
<LogOut size={18} />
</button>
</div>
</div>
</header>
)
}