/** * 貴金屬線上訂購系統 - LINE LIFF React App * Design: Liquid Gold Luxury - Editorial jewelry boutique aesthetic */ const { useState, useEffect, useCallback, createContext, useContext, useRef } = React; // ==================== Context ==================== const AppContext = createContext(); const useApp = () => useContext(AppContext); // ==================== API ==================== const API_BASE = '/api'; const api = { async getConfig() { const res = await fetch(`${API_BASE}/config`); return res.json(); }, async getProducts(params = {}) { const query = new URLSearchParams(params).toString(); const headers = {}; const token = localStorage.getItem('jwt_token'); if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`${API_BASE}/products${query ? '?' + query : ''}`, { headers }); return res.json(); }, async getCategories() { const res = await fetch(`${API_BASE}/categories`); return res.json(); }, async getMetalPrices() { const res = await fetch(`${API_BASE}/shop-prices`); return res.json(); }, async createOrder(data) { const res = await fetch(`${API_BASE}/orders`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); return res.json(); }, async getOrders(token) { const res = await fetch(`${API_BASE}/orders`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) throw new Error('Failed to get orders'); return res.json(); }, async authLiff(accessToken) { const res = await fetch(`${API_BASE}/auth/liff`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ access_token: accessToken }) }); if (!res.ok) throw new Error('Auth failed'); return res.json(); }, async getMe(token) { const res = await fetch(`${API_BASE}/auth/me`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) throw new Error('Unauthorized'); return res.json(); }, async getProfile(token) { const res = await fetch(`${API_BASE}/profile`, { headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) throw new Error('Failed to get profile'); return res.json(); }, async updateProfile(token, data) { const res = await fetch(`${API_BASE}/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(data) }); if (!res.ok) throw new Error('Failed to update profile'); return res.json(); }, async cancelOrder(token, orderNumber) { const res = await fetch(`${API_BASE}/orders/${orderNumber}/cancel`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || '取消失敗'); } return res.json(); }, async confirmReceived(token, orderNumber) { const res = await fetch(`${API_BASE}/orders/${orderNumber}/confirm-received`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}` } }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || '確認收貨失敗'); } return res.json(); } }; // ==================== LIFF Service ==================== const liffService = { liff: null, isLoggedIn: false, profile: null, async init(liffId) { if (!liffId) { console.warn('LIFF ID not configured'); return false; } try { await liff.init({ liffId }); this.liff = liff; this.isLoggedIn = liff.isLoggedIn(); if (this.isLoggedIn) { this.profile = await liff.getProfile(); } return true; } catch (error) { console.error('LIFF init error:', error); return false; } }, login() { if (this.liff && !this.isLoggedIn) { this.liff.login(); } }, async getProfile() { if (this.liff && this.isLoggedIn) { return await this.liff.getProfile(); } return null; }, sendMessage(message) { if (this.liff && this.liff.isInClient()) { return this.liff.sendMessages([{ type: 'text', text: message }]); } }, closeWindow() { if (this.liff && this.liff.isInClient()) { this.liff.closeWindow(); } } }; // ==================== Utility Hooks ==================== const useAnimatedNumber = (value, duration = 500) => { const [displayValue, setDisplayValue] = useState(value); const prevValue = useRef(value); useEffect(() => { const startValue = prevValue.current; const endValue = value; const startTime = Date.now(); const animate = () => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); const current = startValue + (endValue - startValue) * eased; setDisplayValue(Math.round(current)); if (progress < 1) { requestAnimationFrame(animate); } }; animate(); prevValue.current = value; }, [value, duration]); return displayValue; }; // ==================== Components ==================== // Toast 通知 const Toast = ({ message, onClose }) => { useEffect(() => { const timer = setTimeout(onClose, 2500); return () => clearTimeout(timer); }, [onClose]); return (
+ {message}
); }; // Loading const Loading = ({ message = '載入中' }) => (
{message}
); // 金屬價格橫幅(讀取 shop_prices 表) const PriceBanner = ({ prices }) => { const { user, handleLogin, authReady } = useApp(); if (!prices?.has_prices) return null; const formatPrice = (price) => price ? `$${Math.round(price).toLocaleString()}` : '—'; const p = prices.prices || {}; if (!user) { return (
登入查看即時報價
); } return (
黃金 / {p.Au?.display_unit || '錢'}
{formatPrice(p.Au?.sell_price)}
白銀 / {p.Ag?.display_unit || '公斤'}
{formatPrice(p.Ag?.sell_price)}
白金 / {p.Pt?.display_unit || '錢'}
{formatPrice(p.Pt?.sell_price)}
); }; // 金屬圖標 const MetalIcon = ({ type, size = 'md' }) => { const sizes = { sm: 24, md: 40, lg: 56 }; const s = sizes[size] || sizes.md; const colors = { gold: ['#d4a853', '#b8923f'], silver: ['#c9c9c9', '#a0a0a0'], platinum: ['#d8d4cf', '#b5b0a8'], palladium: ['#e0e0e0', '#c0c0c0'] }; const [c1, c2] = colors[type] || colors.gold; return ( ); }; // 商品卡片 const ProductCard = ({ product, onAddToCart, onClick, index = 0 }) => { const { user, cart } = useApp(); const [isAdding, setIsAdding] = useState(false); const currentInCart = cart.find(item => item.id === product.id)?.quantity || 0; const atStockLimit = product.stock_quantity > 0 && currentInCart >= product.stock_quantity; const handleAdd = (e) => { e.stopPropagation(); setIsAdding(true); onAddToCart(product); setTimeout(() => setIsAdding(false), 300); }; const buttonDisabled = product.stock_quantity <= 0 || !user || atStockLimit; const buttonText = !user ? '登入購買' : product.stock_quantity <= 0 ? '補貨中' : atStockLimit ? '已達庫存上限' : '加入購物車'; return (
onClick && onClick(product)} > {product.image_url ? ( {product.name} ) : (
)}
{product.name}
{product.weight_grams && (
{product.weight_grams}g · {(product.purity * 100).toFixed(2)}%
)} {user ? (
NT$ {product.price?.toLocaleString()}
) : (
登入查看
)}
{product.stock_quantity > 0 ? `庫存 ${product.stock_quantity}` : '已售完'}
); }; // 金屬類型中文標籤 const metalTypeLabel = (type) => { const labels = { gold: '黃金', silver: '白銀', platinum: '白金', palladium: '鈀金' }; return labels[type] || type || '—'; }; // 商品詳情彈窗 const ProductDetailModal = ({ product, onClose, onAddToCart }) => { const { user, cart } = useApp(); const currentInCart = cart.find(item => item.id === product.id)?.quantity || 0; const atStockLimit = product.stock_quantity > 0 && currentInCart >= product.stock_quantity; const overlayRef = useRef(null); const contentRef = useRef(null); const closingRef = useRef(false); const animateClose = useCallback(() => { if (closingRef.current) return; closingRef.current = true; const content = contentRef.current; const overlay = overlayRef.current; if (!content || !overlay) { onClose(); return; } Motion.animate(content, { opacity: 0, transform: 'translateY(60px)' }, { duration: 0.2, easing: 'ease-in' }) .then(() => Motion.animate(overlay, { opacity: 0 }, { duration: 0.1 })) .then(() => onClose()); }, [onClose]); useEffect(() => { const overlay = overlayRef.current; const content = contentRef.current; if (!overlay || !content) return; Motion.animate(overlay, { opacity: [0, 1] }, { duration: 0.15 }); Motion.animate(content, { opacity: [0, 1], transform: ['translateY(60px) scale(0.97)', 'translateY(0px) scale(1)'] }, { duration: 0.35, easing: [0.22, 1, 0.36, 1] } ); // ESC 鍵關閉 const handleKey = (e) => { if (e.key === 'Escape') animateClose(); }; document.addEventListener('keydown', handleKey); // 鎖定 body 滾動 document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', handleKey); document.body.style.overflow = ''; }; }, [animateClose]); const handleBackdropClick = (e) => { if (e.target === overlayRef.current) animateClose(); }; const handleAddToCart = () => { if (onAddToCart(product) !== false) { animateClose(); } }; return (
{product.image_url ? ( {product.name} ) : (
)}
{product.name}
{product.description && (
{product.description}
)}
材質
{metalTypeLabel(product.metal_type)}
重量
{product.weight_grams ? `${product.weight_grams}g` : '—'}
純度
{product.purity ? `${(product.purity * 100).toFixed(2)}%` : '—'}
分類
{product.category || '—'}
{user ? (
NT$ {product.price?.toLocaleString()}
) : (
登入查看價格
)}
{product.stock_quantity > 0 ? `庫存 ${product.stock_quantity} 件` : '已售完'}
); }; // 商品列表頁 const ProductList = () => { const { products, cart, addToCart, showToast, metalPrices } = useApp(); const [selectedCategory, setSelectedCategory] = useState('all'); const [selectedProduct, setSelectedProduct] = useState(null); const categories = [...new Set(products.map(p => p.category).filter(Boolean))]; const filteredProducts = selectedCategory === 'all' ? products : products.filter(p => p.category === selectedCategory); const groupedProducts = filteredProducts.reduce((acc, product) => { const category = product.category || '其他'; if (!acc[category]) acc[category] = []; acc[category].push(product); return acc; }, {}); const handleAddToCart = (product) => { if (addToCart(product)) { const newQty = (cart.find(item => item.id === product.id)?.quantity || 0) + 1; const maxStock = product.stock_quantity || 0; showToast(`已加入: ${product.name}(${newQty}/${maxStock})`); return true; } return false; }; return (
{categories.length > 0 && (
{categories.map(cat => ( ))}
)} {Object.entries(groupedProducts).map(([category, items]) => (

{category}

{items.map((product, idx) => ( ))}
))} {products.length === 0 && (

暫無商品

)} {selectedProduct && ( setSelectedProduct(null)} onAddToCart={handleAddToCart} /> )}
); }; // 購物車項目 const CartItem = ({ item, onUpdateQuantity, onRemove }) => { const { products } = useApp(); const stockQuantity = products.find(p => p.id === item.id)?.stock_quantity || 0; const atLimit = item.quantity >= stockQuantity; return (
{item.image_url ? ( {item.name} ) : ( )}
{item.name}
NT$ {item.price?.toLocaleString()}
{item.quantity}
{atLimit && (
已達庫存上限(最多可購買 {stockQuantity} 件)
)}
); }; // 購物車頁面 const CartPage = ({ onCheckout }) => { const { cart, updateCartQuantity, removeFromCart } = useApp(); const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); const shippingFee = subtotal >= 50000 ? 0 : 150; const total = subtotal + shippingFee; const animatedTotal = useAnimatedNumber(total); if (cart.length === 0) { return (

購物車是空的

去看看有什麼好物吧

); } return (
{cart.map((item, index) => ( ))}
小計 NT$ {subtotal.toLocaleString()}
運費 {subtotal >= 50000 && (滿5萬免運)} NT$ {shippingFee}
總計 NT$ {animatedTotal.toLocaleString()}
); }; // 結帳頁面 const PICKUP_STORES = [ { id: 'taoyuan', name: '桃園店', address: '中壢九合一街36號' }, { id: 'yunlin', name: '雲林店', address: '斗六斗工六路45號' }, ]; const PAYMENT_LABELS = { transfer: '銀行轉帳', cash_on_delivery: '貨到付款', store_pickup: '門市取貨付款', }; const CheckoutPage = ({ onBack, onSuccess }) => { const { cart, user, clearCart } = useApp(); const [loading, setLoading] = useState(false); const [form, setForm] = useState({ customer_name: user?.displayName || '', customer_phone: '', customer_email: '', shipping_address: '', payment_method: 'transfer', pickup_store: '', notes: '' }); // 自動帶入用戶 profile 資料(手機、地址、姓名) useEffect(() => { const token = localStorage.getItem('jwt_token'); if (!token || !user) return; api.getProfile(token).then(profile => { if (!profile) return; setForm(prev => ({ ...prev, customer_name: prev.customer_name || profile.name || '', customer_phone: prev.customer_phone || profile.mobile || profile.phone || '', shipping_address: prev.shipping_address || profile.address || '', })); }).catch(() => {}); }, [user]); const isStorePickup = form.payment_method === 'store_pickup'; const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); const shippingFee = isStorePickup ? 0 : (subtotal >= 50000 ? 0 : 150); const total = subtotal + shippingFee; const handleChange = (e) => { const { name, value } = e.target; if (name === 'payment_method') { if (value === 'store_pickup') { setForm(prev => ({ ...prev, payment_method: value, pickup_store: '', shipping_address: '' })); } else { setForm(prev => ({ ...prev, payment_method: value, pickup_store: '', shipping_address: prev.payment_method === 'store_pickup' ? '' : prev.shipping_address, })); } return; } if (name === 'pickup_store') { const store = PICKUP_STORES.find(s => s.id === value); setForm(prev => ({ ...prev, pickup_store: value, shipping_address: store ? `【${store.name}】${store.address}` : '' })); return; } setForm({ ...form, [name]: value }); }; const handleSubmit = async (e) => { e.preventDefault(); if (!form.customer_name || !form.customer_phone) { alert('請填寫必填欄位'); return; } if (isStorePickup && !form.pickup_store) { alert('請選擇取貨門市'); return; } if (!isStorePickup && !form.shipping_address) { alert('請填寫收件地址'); return; } setLoading(true); try { const { pickup_store, ...formData } = form; const orderData = { ...formData, line_user_id: user?.userId || 'guest', line_display_name: user?.displayName, line_picture_url: user?.pictureUrl, items: cart.map(item => ({ product_id: item.id, quantity: item.quantity })), shipping_fee: shippingFee }; const result = await api.createOrder(orderData); if (result.success) { clearCart(); onSuccess(result.order); if (liffService.liff?.isInClient()) { liffService.sendMessage( `訂單已送出\n訂單編號: ${result.order.order_number}\n總金額: NT$ ${total.toLocaleString()}` ); } } else { alert(result.error || '訂單建立失敗'); } } catch (error) { console.error('Order error:', error); alert('訂單建立失敗,請稍後再試'); } finally { setLoading(false); } }; return (

收件資訊

{!isStorePickup && (