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;
관련 글
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
글 읽기 →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).
글 읽기 →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
글 읽기 →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
글 읽기 →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
글 읽기 →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
글 읽기 →