const { useState, useEffect, useCallback, useRef } = React; const App = () => { const [loading, setLoading] = useState(true); const [user, setUser] = useState(null); const [products, setProducts] = useState([]); const [metalPrices, setMetalPrices] = useState(null); const [cart, setCart] = useState(() => { const saved = localStorage.getItem('cart'); return saved ? JSON.parse(saved) : []; }); const [currentPage, setCurrentPage] = useState('products'); const [toast, setToast] = useState(null); const [completedOrder, setCompletedOrder] = useState(null); const [authReady, setAuthReady] = useState(false); const [announcement, setAnnouncement] = useState(null); const logout = useCallback(async () => { try { await api.logout(); } catch (e) { console.error('Logout error:', e); } setUser(null); setAuthReady(false); setTimeout(() => setAuthReady(true), 0); }, []); useEffect(() => { const init = async () => { try { const config = await api.getConfig(); let authenticated = false; try { const me = await api.getMe(); setUser({ displayName: me.name, role: me.role, _ref: me.sid, _authenticated: true }); authenticated = true; } catch (e) {} if (!authenticated && config.liffId) { await liffService.init(config.liffId); if (liffService.isLoggedIn) { try { const liffAccessToken = liff.getAccessToken(); if (liffAccessToken) { const authResult = await api.authLiff(liffAccessToken); const u = authResult.user; setUser({ displayName: u.name, role: u.role, _ref: u.sid, _authenticated: true }); } } catch (e) { console.error('LIFF auth error:', e); if (liffService.profile) { setUser(liffService.profile); } } } } setAuthReady(true); const productsData = await api.getProducts(); setProducts(productsData); const productMap = {}; productsData.forEach(p => { productMap[p.id] = p; }); setCart(prev => prev.map(item => { const latest = productMap[item.id]; if (latest && latest.image_url !== item.image_url) { return { ...item, image_url: latest.image_url }; } return item; })); try { const prices = await api.getMetalPrices(); setMetalPrices(prices); } catch (e) { console.log('Metal prices not available'); } try { const announcementData = await api.getAnnouncement(); if (announcementData.enabled && announcementData.content) { if (announcementData.display_mode === 'always') { setAnnouncement(announcementData); } else { const readVersion = localStorage.getItem('shop_announcement_read_version'); if (readVersion !== announcementData.version) { setAnnouncement(announcementData); } } } } catch (e) { console.log('Announcement not available'); } } catch (error) { console.error('Init error:', error); } finally { setLoading(false); } }; init(); }, []); useEffect(() => { let ws; let reconnectTimer; const connect = () => { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(`${protocol}//${location.host}/ws/prices`); ws.onmessage = async (evt) => { try { const data = JSON.parse(evt.data); if (data.event === 'price-updated') { const [productsData, pricesData] = await Promise.all([ api.getProducts(), api.getMetalPrices(), ]); setProducts(productsData); setMetalPrices(pricesData); } else if (data.event === 'stock-updated') { const productsData = await api.getProducts(); setProducts(productsData); } } catch (e) {} }; ws.onclose = () => { reconnectTimer = setTimeout(connect, 5000); }; }; connect(); return () => { clearTimeout(reconnectTimer); if (ws) ws.close(); }; }, []); useEffect(() => { localStorage.setItem('cart', JSON.stringify(cart)); }, [cart]); const productsRef = useRef(products); productsRef.current = products; const cartRef = useRef(cart); cartRef.current = cart; const showToast = useCallback((message) => { setToast(message); }, []); const addToCart = useCallback((product) => { const latestProduct = productsRef.current.find(p => p.id === product.id); const maxStock = latestProduct?.stock_quantity || 0; const currentInCart = cartRef.current.find(item => item.id === product.id)?.quantity || 0; if (currentInCart >= maxStock) { showToast(`已達庫存上限(購物車 ${currentInCart} 件/庫存 ${maxStock} 件)`); return false; } setCart(prev => { const existing = prev.find(item => item.id === product.id); if (existing) { return prev.map(item => item.id === product.id ? { ...item, quantity: Math.min(item.quantity + 1, maxStock) } : item ); } return [...prev, { ...product, quantity: 1 }]; }); return true; }, [showToast]); const updateCartQuantity = useCallback((productId, quantity) => { if (quantity <= 0) { setCart(prev => prev.filter(item => item.id !== productId)); } else { const latestProduct = productsRef.current.find(p => p.id === productId); const maxStock = latestProduct?.stock_quantity ?? 0; const cappedQty = maxStock > 0 ? Math.min(quantity, maxStock) : 1; setCart(prev => prev.map(item => item.id === productId ? { ...item, quantity: cappedQty } : item )); } }, []); const removeFromCart = useCallback((productId) => { setCart(prev => prev.filter(item => item.id !== productId)); }, []); const clearCart = useCallback(() => { setCart([]); }, []); const cartCount = cart.reduce((sum, item) => sum + item.quantity, 0); const handleLogin = useCallback(() => { if (liffService.liff && liffService.liff.isInClient()) { liffService.login(); } else { window.location.href = '/api/auth/line'; } }, []); const contextValue = { user, products, metalPrices, cart, addToCart, updateCartQuantity, removeFromCart, clearCart, showToast, handleLogin, logout, authReady, }; if (loading) { return (
); } const renderPage = () => { if (completedOrder) { return ( { setCompletedOrder(null); setCurrentPage('products'); }} /> ); } switch (currentPage) { case 'products': return ; case 'cart': return setCurrentPage('checkout')} />; case 'checkout': return ( setCurrentPage('cart')} onSuccess={(order) => setCompletedOrder(order)} /> ); case 'profile': return ; default: return ; } }; return (
{renderPage()} {toast && setToast(null)} />} {announcement && ( setAnnouncement(null)} /> )}
); }; ReactDOM.createRoot(document.getElementById('root')).render();