S SmartDocs
Série: React.js javascript 217 lignes · Mis à jour 2026-04-02

Project_ProductCatalog.jsx

React.js/lessons/08_lists_and_keys/projects/Project_ProductCatalog.jsx

/**
 * Project: 產品目錄 — 列表與 Keys 練習
 *
 * 學習重點:
 * - map() 渲染物件陣列
 * - Key 的正確使用(使用唯一 ID)
 * - filter() 過濾資料
 * - sort() 排序資料(注意不可變性)
 * - 搜尋功能
 * - 結合 useState 管理篩選與排序狀態
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState } from 'react';

const PRODUCTS = [
  { id: 1, name: 'MacBook Air M3', price: 35900, category: '筆電', rating: 4.8, reviews: 342, image: '💻', inStock: true, isNew: true },
  { id: 2, name: 'iPhone 15 Pro', price: 36900, category: '手機', rating: 4.7, reviews: 1205, image: '📱', inStock: true, isNew: true },
  { id: 3, name: 'AirPods Pro 2', price: 7490, category: '耳機', rating: 4.6, reviews: 856, image: '🎧', inStock: true, isNew: false },
  { id: 4, name: 'iPad Air M2', price: 20900, category: '平板', rating: 4.7, reviews: 420, image: '📱', inStock: false, isNew: false },
  { id: 5, name: 'Apple Watch Ultra 2', price: 27900, category: '穿戴', rating: 4.5, reviews: 198, image: '⌚', inStock: true, isNew: true },
  { id: 6, name: 'Magic Keyboard', price: 9990, category: '配件', rating: 4.3, reviews: 523, image: '⌨️', inStock: true, isNew: false },
  { id: 7, name: 'Studio Display', price: 47900, category: '螢幕', rating: 4.4, reviews: 87, image: '🖥️', inStock: true, isNew: false },
  { id: 8, name: 'AirPods Max', price: 18490, category: '耳機', rating: 4.5, reviews: 312, image: '🎧', inStock: false, isNew: false },
  { id: 9, name: 'Mac Mini M2', price: 18900, category: '桌機', rating: 4.6, reviews: 267, image: '🖥️', inStock: true, isNew: false },
  { id: 10, name: 'HomePod mini', price: 3000, category: '配件', rating: 4.2, reviews: 645, image: '🔊', inStock: true, isNew: false },
  { id: 11, name: 'MacBook Pro 16" M3 Pro', price: 79900, category: '筆電', rating: 4.9, reviews: 189, image: '💻', inStock: true, isNew: true },
  { id: 12, name: 'iPhone 15', price: 29900, category: '手機', rating: 4.5, reviews: 2100, image: '📱', inStock: true, isNew: false },
];

function ProductCatalog() {
  const [searchTerm, setSearchTerm] = useState('');
  const [selectedCategory, setSelectedCategory] = useState('all');
  const [sortBy, setSortBy] = useState('name');
  const [sortOrder, setSortOrder] = useState('asc');
  const [showInStockOnly, setShowInStockOnly] = useState(false);
  const [viewMode, setViewMode] = useState('grid');

  const categories = ['all', ...new Set(PRODUCTS.map((p) => p.category))];

  const filteredProducts = PRODUCTS
    .filter((p) => {
      const matchSearch = p.name.toLowerCase().includes(searchTerm.toLowerCase());
      const matchCategory = selectedCategory === 'all' || p.category === selectedCategory;
      const matchStock = !showInStockOnly || p.inStock;
      return matchSearch && matchCategory && matchStock;
    })
    .sort((a, b) => {
      const mod = sortOrder === 'asc' ? 1 : -1;
      if (sortBy === 'name') return a.name.localeCompare(b.name) * mod;
      if (sortBy === 'price') return (a.price - b.price) * mod;
      if (sortBy === 'rating') return (a.rating - b.rating) * mod;
      if (sortBy === 'reviews') return (a.reviews - b.reviews) * mod;
      return 0;
    });

  const toggleSort = (field) => {
    if (sortBy === field) {
      setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
    } else {
      setSortBy(field);
      setSortOrder('asc');
    }
  };

  const SortButton = ({ field, label }) => (
    <button
      onClick={() => toggleSort(field)}
      style={{
        padding: '4px 12px', borderRadius: '6px', border: 'none', fontSize: '0.8rem',
        backgroundColor: sortBy === field ? '#4f46e5' : '#f1f5f9',
        color: sortBy === field ? 'white' : '#64748b',
        cursor: 'pointer',
      }}
    >
      {label} {sortBy === field && (sortOrder === 'asc' ? '↑' : '↓')}
    </button>
  );

  const StarRating = ({ rating }) => (
    <span style={{ color: '#f59e0b', fontSize: '0.85rem' }}>
      {'★'.repeat(Math.floor(rating))}
      {'☆'.repeat(5 - Math.floor(rating))}
      <span style={{ color: '#94a3b8', marginLeft: '4px' }}>{rating}</span>
    </span>
  );

  const ProductCard = ({ product }) => (
    <div style={{
      backgroundColor: 'white', borderRadius: '12px', border: '1px solid #f1f5f9',
      padding: '20px', display: 'flex', flexDirection: viewMode === 'grid' ? 'column' : 'row',
      gap: '16px', position: 'relative', opacity: product.inStock ? 1 : 0.6,
      transition: 'box-shadow 0.2s',
    }}>
      {product.isNew && (
        <span style={{
          position: 'absolute', top: '12px', right: '12px', padding: '2px 8px', borderRadius: '6px',
          backgroundColor: '#dbeafe', color: '#1d4ed8', fontSize: '0.7rem', fontWeight: 'bold',
        }}>NEW</span>
      )}
      <div style={{
        fontSize: viewMode === 'grid' ? '3rem' : '2.5rem',
        textAlign: 'center',
        padding: viewMode === 'grid' ? '20px 0' : '0',
        flexShrink: 0,
      }}>
        {product.image}
      </div>
      <div style={{ flex: 1 }}>
        <span style={{ color: '#94a3b8', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '1px' }}>
          {product.category}
        </span>
        <h3 style={{ margin: '4px 0 8px', color: '#0f172a', fontSize: '1rem' }}>{product.name}</h3>
        <StarRating rating={product.rating} />
        <span style={{ color: '#94a3b8', fontSize: '0.8rem', marginLeft: '4px' }}>({product.reviews})</span>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px' }}>
          <span style={{ fontSize: '1.2rem', fontWeight: 'bold', color: '#4f46e5' }}>
            NT$ {product.price.toLocaleString()}
          </span>
          {product.inStock ? (
            <button style={{
              padding: '6px 16px', borderRadius: '8px', border: 'none',
              backgroundColor: '#4f46e5', color: 'white', cursor: 'pointer', fontSize: '0.85rem',
            }}>加入購物車</button>
          ) : (
            <span style={{ color: '#ef4444', fontSize: '0.85rem', fontWeight: '600' }}>缺貨中</span>
          )}
        </div>
      </div>
    </div>
  );

  return (
    <div style={{ maxWidth: '1000px', margin: '0 auto', padding: '32px 16px', fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif" }}>
      <h1 style={{ color: '#0f172a', marginBottom: '24px' }}>產品目錄</h1>

      {/* 搜尋與篩選 */}
      <div style={{ marginBottom: '20px' }}>
        <input
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="🔍  搜尋產品..."
          style={{
            width: '100%', padding: '12px 16px', border: '2px solid #e2e8f0',
            borderRadius: '10px', fontSize: '1rem', outline: 'none', boxSizing: 'border-box',
          }}
        />
      </div>

      <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center', marginBottom: '16px' }}>
        {/* 分類 */}
        {categories.map((cat) => (
          <button
            key={cat}
            onClick={() => setSelectedCategory(cat)}
            style={{
              padding: '6px 16px', borderRadius: '20px', border: 'none',
              backgroundColor: selectedCategory === cat ? '#4f46e5' : '#f1f5f9',
              color: selectedCategory === cat ? 'white' : '#64748b',
              cursor: 'pointer', fontSize: '0.85rem',
            }}
          >
            {cat === 'all' ? '全部' : cat}
          </button>
        ))}

        <div style={{ width: '1px', height: '20px', backgroundColor: '#e2e8f0', margin: '0 4px' }} />

        {/* 有庫存 */}
        <label style={{ display: 'flex', alignItems: 'center', gap: '6px', cursor: 'pointer', fontSize: '0.85rem', color: '#64748b' }}>
          <input type="checkbox" checked={showInStockOnly} onChange={(e) => setShowInStockOnly(e.target.checked)} style={{ accentColor: '#4f46e5' }} />
          僅顯示有庫存
        </label>
      </div>

      {/* 排序與顯示模式 */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
        <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
          <span style={{ color: '#94a3b8', fontSize: '0.8rem' }}>排序:</span>
          <SortButton field="name" label="名稱" />
          <SortButton field="price" label="價格" />
          <SortButton field="rating" label="評分" />
          <SortButton field="reviews" label="評論數" />
        </div>
        <div style={{ display: 'flex', gap: '4px' }}>
          <button onClick={() => setViewMode('grid')} style={{ padding: '6px 10px', borderRadius: '6px', border: 'none', backgroundColor: viewMode === 'grid' ? '#e2e8f0' : 'transparent', cursor: 'pointer' }}>▦</button>
          <button onClick={() => setViewMode('list')} style={{ padding: '6px 10px', borderRadius: '6px', border: 'none', backgroundColor: viewMode === 'list' ? '#e2e8f0' : 'transparent', cursor: 'pointer' }}>☰</button>
        </div>
      </div>

      <p style={{ color: '#94a3b8', fontSize: '0.85rem', margin: '0 0 16px' }}>
        顯示 {filteredProducts.length} / {PRODUCTS.length} 項產品
      </p>

      {/* 產品列表 */}
      {filteredProducts.length === 0 ? (
        <div style={{ textAlign: 'center', padding: '60px 0', color: '#cbd5e1' }}>
          <p style={{ fontSize: '3rem', margin: '0 0 8px' }}>🔍</p>
          <p>找不到符合條件的產品</p>
        </div>
      ) : (
        <div style={{
          display: 'grid',
          gridTemplateColumns: viewMode === 'grid' ? 'repeat(auto-fill, minmax(240px, 1fr))' : '1fr',
          gap: '16px',
        }}>
          {filteredProducts.map((product) => (
            <ProductCard key={product.id} product={product} />
          ))}
        </div>
      )}
    </div>
  );
}

export default ProductCatalog;

Articles liés