Project_ShoppingCart.jsx
React.js/lessons/13_useReducer/projects/Project_ShoppingCart.jsx
/**
* Project: 購物車 — useReducer 練習
*
* 學習重點:
* - useReducer 管理複雜狀態(購物車 CRUD)
* - Action types 和 payload
* - 不可變性更新(在 reducer 中)
* - 計算派生狀態(小計、總計)
*
* 使用方式:將此檔案內容複製到你的 App.jsx 中
*/
import { useReducer, useState } from 'react';
const PRODUCTS = [
{ id: 1, name: 'React 實戰手冊', price: 580, image: '📘', category: '書籍' },
{ id: 2, name: 'JavaScript 精要', price: 520, image: '📗', category: '書籍' },
{ id: 3, name: '機械鍵盤 K2', price: 2990, image: '⌨️', category: '周邊' },
{ id: 4, name: '程式設計師滑鼠墊', price: 390, image: '🖱️', category: '周邊' },
{ id: 5, name: 'USB-C Hub', price: 1290, image: '🔌', category: '周邊' },
{ id: 6, name: 'TypeScript 入門', price: 450, image: '📕', category: '書籍' },
{ id: 7, name: '降噪耳機', price: 3590, image: '🎧', category: '3C' },
{ id: 8, name: '27 吋螢幕', price: 8990, image: '🖥️', category: '3C' },
];
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find((i) => i.id === action.payload.id);
if (existing) {
return { ...state, items: state.items.map((i) => i.id === action.payload.id ? { ...i, quantity: i.quantity + 1 } : i) };
}
return { ...state, items: [...state.items, { ...action.payload, quantity: 1 }] };
}
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter((i) => i.id !== action.payload) };
case 'UPDATE_QTY': {
const { id, qty } = action.payload;
if (qty <= 0) return { ...state, items: state.items.filter((i) => i.id !== id) };
return { ...state, items: state.items.map((i) => i.id === id ? { ...i, quantity: qty } : i) };
}
case 'CLEAR':
return { ...state, items: [] };
case 'APPLY_COUPON':
return { ...state, coupon: action.payload };
case 'REMOVE_COUPON':
return { ...state, coupon: null };
default:
return state;
}
}
const coupons = {
SAVE10: { discount: 10, label: '9 折優惠' },
SAVE20: { discount: 20, label: '8 折優惠' },
FREESHIP: { discount: 0, label: '免運費', freeShip: true },
};
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, { items: [], coupon: null });
const [couponCode, setCouponCode] = useState('');
const [couponError, setCouponError] = useState('');
const subtotal = state.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const couponInfo = state.coupon ? coupons[state.coupon] : null;
const discountAmount = couponInfo ? Math.round(subtotal * (couponInfo.discount / 100)) : 0;
const shipping = (couponInfo?.freeShip || subtotal >= 1000) ? 0 : 60;
const total = subtotal - discountAmount + shipping;
const totalItems = state.items.reduce((sum, i) => sum + i.quantity, 0);
const applyCoupon = () => {
if (coupons[couponCode.toUpperCase()]) {
dispatch({ type: 'APPLY_COUPON', payload: couponCode.toUpperCase() });
setCouponError('');
setCouponCode('');
} else {
setCouponError('無效的優惠碼');
}
};
return (
<div style={{ maxWidth: '1000px', margin: '0 auto', padding: '32px 16px', fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif" }}>
<h1 style={{ color: '#0f172a', marginBottom: '32px' }}>
購物商店
</h1>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: '32px', alignItems: 'start' }}>
{/* 商品列表 */}
<div>
<h2 style={{ color: '#475569', fontSize: '1rem', marginBottom: '16px' }}>所有商品</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{PRODUCTS.map((p) => {
const inCart = state.items.find((i) => i.id === p.id);
return (
<div key={p.id} style={{
padding: '20px', borderRadius: '12px', border: '1px solid #f1f5f9',
backgroundColor: 'white', textAlign: 'center',
}}>
<div style={{ fontSize: '2.5rem', marginBottom: '8px' }}>{p.image}</div>
<span style={{ color: '#94a3b8', fontSize: '0.7rem', textTransform: 'uppercase' }}>{p.category}</span>
<h3 style={{ margin: '4px 0', color: '#0f172a', fontSize: '0.95rem' }}>{p.name}</h3>
<p style={{ color: '#4f46e5', fontWeight: 'bold', fontSize: '1.1rem', margin: '8px 0' }}>
NT$ {p.price.toLocaleString()}
</p>
<button
onClick={() => dispatch({ type: 'ADD_ITEM', payload: p })}
style={{
width: '100%', padding: '8px', borderRadius: '8px', border: 'none',
backgroundColor: inCart ? '#22c55e' : '#4f46e5', color: 'white',
cursor: 'pointer', fontSize: '0.85rem', fontWeight: '600',
}}
>
{inCart ? `已加入 (${inCart.quantity})` : '加入購物車'}
</button>
</div>
);
})}
</div>
</div>
{/* 購物車 */}
<div style={{
padding: '24px', borderRadius: '16px', backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0', position: 'sticky', top: '20px',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2 style={{ margin: 0, color: '#0f172a', fontSize: '1.1rem' }}>
購物車
{totalItems > 0 && (
<span style={{
marginLeft: '8px', padding: '2px 8px', borderRadius: '10px',
backgroundColor: '#4f46e5', color: 'white', fontSize: '0.75rem',
}}>
{totalItems}
</span>
)}
</h2>
{state.items.length > 0 && (
<button onClick={() => dispatch({ type: 'CLEAR' })} style={{
background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: '0.8rem',
}}>
清空
</button>
)}
</div>
{state.items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#cbd5e1' }}>
<p style={{ fontSize: '2rem', margin: '0 0 8px' }}>🛒</p>
<p style={{ margin: 0 }}>購物車是空的</p>
</div>
) : (
<>
{state.items.map((item) => (
<div key={item.id} style={{
display: 'flex', gap: '12px', alignItems: 'center',
padding: '12px 0', borderBottom: '1px solid #e2e8f0',
}}>
<span style={{ fontSize: '1.5rem' }}>{item.image}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#0f172a', fontWeight: '500' }}>{item.name}</p>
<p style={{ margin: '2px 0 0', fontSize: '0.8rem', color: '#64748b' }}>
NT$ {item.price.toLocaleString()}
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<button onClick={() => dispatch({ type: 'UPDATE_QTY', payload: { id: item.id, qty: item.quantity - 1 } })}
style={{ width: '28px', height: '28px', borderRadius: '6px', border: '1px solid #e2e8f0', backgroundColor: 'white', cursor: 'pointer' }}>
−
</button>
<span style={{ width: '24px', textAlign: 'center', fontSize: '0.9rem', fontWeight: '600' }}>{item.quantity}</span>
<button onClick={() => dispatch({ type: 'UPDATE_QTY', payload: { id: item.id, qty: item.quantity + 1 } })}
style={{ width: '28px', height: '28px', borderRadius: '6px', border: '1px solid #e2e8f0', backgroundColor: 'white', cursor: 'pointer' }}>
+
</button>
</div>
<span style={{ fontSize: '0.85rem', fontWeight: '600', color: '#0f172a', width: '70px', textAlign: 'right' }}>
${(item.price * item.quantity).toLocaleString()}
</span>
</div>
))}
{/* 優惠碼 */}
<div style={{ marginTop: '16px' }}>
{state.coupon ? (
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '8px 12px', borderRadius: '8px', backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0',
}}>
<span style={{ color: '#166534', fontSize: '0.85rem' }}>🎉 {couponInfo.label} ({state.coupon})</span>
<button onClick={() => dispatch({ type: 'REMOVE_COUPON' })} style={{
background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: '0.8rem',
}}>移除</button>
</div>
) : (
<div style={{ display: 'flex', gap: '8px' }}>
<input value={couponCode} onChange={(e) => { setCouponCode(e.target.value); setCouponError(''); }}
placeholder="輸入優惠碼" style={{
flex: 1, padding: '8px 12px', borderRadius: '8px', fontSize: '0.85rem',
border: `1px solid ${couponError ? '#fca5a5' : '#e2e8f0'}`, outline: 'none',
}}
/>
<button onClick={applyCoupon} style={{
padding: '8px 16px', borderRadius: '8px', border: 'none',
backgroundColor: '#4f46e5', color: 'white', cursor: 'pointer', fontSize: '0.85rem',
}}>套用</button>
</div>
)}
{couponError && <p style={{ color: '#ef4444', fontSize: '0.75rem', margin: '4px 0 0' }}>{couponError}</p>}
<p style={{ color: '#94a3b8', fontSize: '0.7rem', margin: '4px 0 0' }}>試試 SAVE10、SAVE20 或 FREESHIP</p>
</div>
{/* 金額明細 */}
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '2px solid #e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px', fontSize: '0.9rem' }}>
<span style={{ color: '#64748b' }}>小計</span>
<span style={{ color: '#0f172a' }}>NT$ {subtotal.toLocaleString()}</span>
</div>
{discountAmount > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px', fontSize: '0.9rem' }}>
<span style={{ color: '#22c55e' }}>折扣</span>
<span style={{ color: '#22c55e' }}>-NT$ {discountAmount.toLocaleString()}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '6px', fontSize: '0.9rem' }}>
<span style={{ color: '#64748b' }}>運費</span>
<span style={{ color: shipping === 0 ? '#22c55e' : '#0f172a' }}>
{shipping === 0 ? '免運費' : `NT$ ${shipping}`}
</span>
</div>
<div style={{
display: 'flex', justifyContent: 'space-between', paddingTop: '12px', marginTop: '8px',
borderTop: '2px solid #0f172a', fontWeight: 'bold', fontSize: '1.1rem',
}}>
<span>總計</span>
<span style={{ color: '#4f46e5' }}>NT$ {total.toLocaleString()}</span>
</div>
</div>
<button style={{
width: '100%', marginTop: '20px', padding: '14px', borderRadius: '12px', border: 'none',
backgroundColor: '#4f46e5', color: 'white', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem',
}}>
前往結帳
</button>
</>
)}
</div>
</div>
</div>
);
}
export default ShoppingCart;
Artigos relacionados
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Ler artigo →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
Ler artigo →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Ler artigo →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Ler artigo →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Ler artigo →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Ler artigo →