Spaces:
Running
Running
| 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> | |
| ) | |
| } | |