/**
* 貴金屬線上訂購系統 - 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 = '載入中' }) => (
);
// 金屬價格橫幅(讀取 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.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.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}
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 (
);
};
// 訂單成功頁
const OrderSuccessPage = ({ order, onContinue }) => (
訂單已送出
訂單編號: {order.order_number}
總金額: NT$ {order.total_amount?.toLocaleString()}
我們會盡快處理您的訂單
請留意 LINE 訊息通知
);
// 訂單記錄頁
const OrdersPage = () => {
const { user, showToast, handleLogin, authReady } = useApp();
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [confirmingOrder, setConfirmingOrder] = useState(null);
const fetchOrders = async () => {
const token = localStorage.getItem('jwt_token');
if (user?.userId && token) {
try {
const data = await api.getOrders(token);
setOrders(data);
} catch (error) {
console.error('Fetch orders error:', error);
}
}
setLoading(false);
};
useEffect(() => { fetchOrders(); }, [user]);
const handleConfirmReceived = async (orderNumber) => {
const token = localStorage.getItem('jwt_token');
if (!token) return;
setConfirmingOrder(orderNumber);
try {
const result = await api.confirmReceived(token, orderNumber);
setOrders(prev => prev.map(o =>
o.order_number === orderNumber ? result.order : o
));
showToast('已確認收貨');
} catch (error) {
showToast(error.message || '確認收貨失敗');
}
setConfirmingOrder(null);
};
const statusText = {
pending: '待確認',
pending_payment: '待付款',
paid: '已付款',
confirmed: '已確認',
shipped: '已出貨',
ready_pickup: '待取貨',
completed: '已完成',
cancelled: '已取消',
rejected: '已拒絕'
};
const receivableStatuses = new Set(['shipped', 'ready_pickup']);
if (loading) return ;
if (!user) {
return (
請先登入查看訂單
{authReady && (
)}
);
}
if (orders.length === 0) {
return (
);
}
return (
{orders.map((order, index) => (
{order.order_number}
{statusText[order.status] || order.status}
{order.items?.slice(0, 2).map(item => item.product_name).join(', ')}
{order.items?.length > 2 && ` 等 ${order.items.length} 件商品`}
{PAYMENT_LABELS[order.payment_method] || order.payment_method}
{order.payment_method === 'store_pickup' && order.shipping_address && (
{order.shipping_address}
)}
NT$ {order.total_amount?.toLocaleString()}
{new Date(order.created_at).toLocaleString('zh-TW')}
{receivableStatuses.has(order.status) && (
)}
))}
);
};
// 個人資料編輯彈窗
const ProfileEditModal = ({ profile, onClose, onSaved }) => {
const { showToast } = useApp();
const overlayRef = useRef(null);
const contentRef = useRef(null);
const closingRef = useRef(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
name: profile?.name || '',
company: profile?.company || '',
tax_id: profile?.tax_id || '',
contact_person: profile?.contact_person || '',
mobile: profile?.mobile || '',
phone: profile?.phone || '',
address: profile?.address || '',
});
const fieldLabels = [
{ key: 'name', label: '姓名' },
{ key: 'company', label: '公司' },
{ key: 'tax_id', label: '統一編號' },
{ key: 'contact_person', label: '聯絡人' },
{ key: 'mobile', label: '手機' },
{ key: 'phone', label: '電話' },
{ key: 'address', label: '地址' },
];
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] }
);
const handleKey = (e) => { if (e.key === 'Escape') animateClose(); };
document.addEventListener('keydown', handleKey);
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 handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSave = async () => {
const token = localStorage.getItem('jwt_token');
if (!token) return;
setSaving(true);
try {
const updated = await api.updateProfile(token, form);
showToast('資料已更新');
onSaved(updated);
onClose();
} catch (error) {
console.error('Profile update error:', error);
showToast('更新失敗,請稍後再試');
setSaving(false);
}
};
return (
{fieldLabels.map(({ key, label }) => (
{key === 'address' ? (
) : (
)}
))}
);
};
// 個人資料頁
const ProfilePage = () => {
const { user, handleLogin, logout, authReady, showToast } = useApp();
const [profile, setProfile] = useState(null);
const [orders, setOrders] = useState([]);
const [loading, setLoading] = useState(true);
const [showEditModal, setShowEditModal] = useState(false);
const [cancellingOrder, setCancellingOrder] = useState(null);
const [confirmingOrder, setConfirmingOrder] = useState(null);
useEffect(() => {
const fetchData = async () => {
const token = localStorage.getItem('jwt_token');
if (!token || !user) {
setLoading(false);
return;
}
try {
const [profileData, ordersData] = await Promise.all([
api.getProfile(token),
api.getOrders(token)
]);
setProfile(profileData);
setOrders(ordersData);
} catch (error) {
console.error('Profile fetch error:', error);
}
setLoading(false);
};
fetchData();
}, [user]);
const handleCancelOrder = async (orderNumber) => {
if (!confirm('確定要取消此訂單嗎?')) return;
const token = localStorage.getItem('jwt_token');
if (!token) return;
setCancellingOrder(orderNumber);
try {
const result = await api.cancelOrder(token, orderNumber);
setOrders(prev => prev.map(o =>
o.order_number === orderNumber ? result.order : o
));
showToast('訂單已取消');
} catch (error) {
showToast(error.message || '取消失敗');
}
setCancellingOrder(null);
};
const handleConfirmReceived = async (orderNumber) => {
if (!confirm('確認已收到商品嗎?')) return;
const token = localStorage.getItem('jwt_token');
if (!token) return;
setConfirmingOrder(orderNumber);
try {
const result = await api.confirmReceived(token, orderNumber);
setOrders(prev => prev.map(o =>
o.order_number === orderNumber ? result.order : o
));
showToast('已確認收貨');
} catch (error) {
showToast(error.message || '確認收貨失敗');
}
setConfirmingOrder(null);
};
const statusText = {
pending: '待確認', pending_payment: '待付款', paid: '已付款',
confirmed: '已確認', shipped: '已出貨', ready_pickup: '待取貨',
completed: '已完成', cancelled: '已取消', rejected: '已拒絕'
};
const cancellableStatuses = new Set(['pending', 'pending_payment', 'confirmed']);
const receivableStatuses = new Set(['shipped', 'ready_pickup']);
const roleBadgeLabel = { customer: '一般客戶', jeweler: '銀樓', admin: '管理員' };
if (loading) return ;
if (!user) {
return (
請先登入
{authReady && (
)}
);
}
return (
{/* 金屬卡片 */}
{user.pictureUrl ? (

) : (
)}
{profile?.name || user.displayName}
{profile?.company &&
{profile.company}
}
{roleBadgeLabel[profile?.role || user.role] || '一般客戶'}
{/* 登出 */}
{/* 歷史訂單 */}
{orders.length > 0 && (
歷史訂單
{orders.map((order, index) => (
{order.order_number}
{statusText[order.status] || order.status}
{order.items?.map((item, i) => (
{item.product_name}
x{item.quantity}
NT$ {item.subtotal?.toLocaleString()}
))}
{PAYMENT_LABELS[order.payment_method] || order.payment_method}
{order.payment_method === 'store_pickup' && order.shipping_address && (
{order.shipping_address}
)}
NT$ {order.total_amount?.toLocaleString()}
{new Date(order.created_at).toLocaleString('zh-TW')}
{receivableStatuses.has(order.status) && (
)}
{cancellableStatuses.has(order.status) && (
)}
))}
)}
{/* 編輯彈窗 */}
{showEditModal && (
setShowEditModal(false)}
onSaved={(updated) => setProfile(updated)}
/>
)}
);
};
// ==================== Main App ====================
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 logout = useCallback(() => {
localStorage.removeItem('jwt_token');
setUser(null);
setAuthReady(false);
setTimeout(() => setAuthReady(true), 0);
}, []);
// 初始化
useEffect(() => {
const init = async () => {
try {
const config = await api.getConfig();
// --- JWT 認證流程 ---
// 1) 檢查 URL hash fragment #token=(OAuth callback 回來的 JWT)
const hash = window.location.hash;
if (hash.startsWith('#token=')) {
const urlToken = hash.substring(7);
localStorage.setItem('jwt_token', urlToken);
// 清除 hash
window.history.replaceState({}, '', window.location.pathname);
}
const storedToken = localStorage.getItem('jwt_token');
// 2) 嘗試從 localStorage 還原 session
if (storedToken) {
try {
const me = await api.getMe(storedToken);
setUser({
userId: me.line_uid,
displayName: me.name,
pictureUrl: me.picture_url,
role: me.role,
_authenticated: true
});
} catch (e) {
// JWT 過期或無效 → 清除
localStorage.removeItem('jwt_token');
}
}
// 3) 若無 JWT 但有 LIFF → 用 LIFF access token 換 JWT
if (!localStorage.getItem('jwt_token') && config.liffId) {
await liffService.init(config.liffId);
if (liffService.isLoggedIn) {
try {
const liffAccessToken = liff.getAccessToken();
if (liffAccessToken) {
const authResult = await api.authLiff(liffAccessToken);
localStorage.setItem('jwt_token', authResult.token);
const u = authResult.user;
setUser({
userId: u.line_uid,
displayName: u.name,
pictureUrl: u.picture_url,
role: u.role,
_authenticated: true
});
}
} catch (e) {
console.error('LIFF auth error:', e);
// 降級:僅用 LIFF profile(未驗證)
if (liffService.profile) {
setUser(liffService.profile);
}
}
}
// LIFF 已初始化但未登入 → 之後由 UI 觸發登入
}
setAuthReady(true);
// --- 載入商品 ---
const productsData = await api.getProducts();
setProducts(productsData);
// 同步購物車中的商品圖片(localStorage 可能有舊資料)
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');
}
} catch (error) {
console.error('Init error:', error);
} finally {
setLoading(false);
}
};
init();
}, []);
// WebSocket:即時接收價格/庫存更新事件,自動刷新商品和價格
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) { /* ignore */ }
};
ws.onclose = () => {
reconnectTimer = setTimeout(connect, 5000);
};
};
connect();
return () => {
clearTimeout(reconnectTimer);
if (ws) ws.close();
};
}, []);
// 儲存購物車到 localStorage
useEffect(() => {
localStorage.setItem('cart', JSON.stringify(cart));
}, [cart]);
// 購物車操作
const productsRef = useRef(products);
productsRef.current = products;
const cartRef = useRef(cart);
cartRef.current = cart;
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 showToast = useCallback((message) => {
setToast(message);
}, []);
const cartCount = cart.reduce((sum, item) => sum + item.quantity, 0);
// 登入處理
const handleLogin = useCallback(() => {
if (liffService.liff && liffService.liff.isInClient()) {
// LIFF 內:呼叫 liff.login()
liffService.login();
} else {
// 一般瀏覽器:導向 OAuth 流程
window.location.href = '/api/auth/line';
}
}, []);
// Context value
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)} />}
);
};
// 渲染
ReactDOM.createRoot(document.getElementById('root')).render();