S SmartDocs
시리즈: React.js javascript 254 줄 · 업데이트 2026-04-02

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;

관련 글