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

Project_RestaurantMenu.jsx

React.js/lessons/03_components/projects/Project_RestaurantMenu.jsx

/**
 * Project: 餐廳菜單頁面 — 元件組合練習
 *
 * 學習重點:
 * - 將 UI 拆分成多個獨立元件
 * - 元件組合 (Composition)
 * - 元件匯出 / 匯入概念
 * - 陣列渲染
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

// ============================================================
// 資料
// ============================================================
const menuData = {
  restaurant: {
    name: '和風亭',
    subtitle: '日式料理 · 精緻定食',
    description: '嚴選新鮮食材,傳承日本職人精神',
  },
  categories: [
    {
      id: 'appetizer',
      name: '前菜',
      icon: '🥗',
      items: [
        { id: 1, name: '和風沙拉', price: 120, description: '新鮮生菜佐和風醬', popular: false, spicy: false },
        { id: 2, name: '毛豆', price: 60, description: '鹽味毛豆,開胃小品', popular: true, spicy: false },
        { id: 3, name: '胡麻豆腐', price: 90, description: '手作豆腐淋胡麻醬', popular: false, spicy: false },
      ],
    },
    {
      id: 'main',
      name: '主餐',
      icon: '🍱',
      items: [
        { id: 4, name: '照燒雞腿定食', price: 320, description: '嫩煎雞腿佐照燒醬,附白飯、味噌湯、小菜三品', popular: true, spicy: false },
        { id: 5, name: '炸蝦天婦羅定食', price: 360, description: '酥脆炸蝦天婦羅,附白飯、味噌湯', popular: true, spicy: false },
        { id: 6, name: '味噌豬排定食', price: 340, description: '厚切豬排淋味噌醬', popular: false, spicy: false },
        { id: 7, name: '辣味擔擔烏龍麵', price: 280, description: '濃郁芝麻辣湯底搭配Q彈烏龍麵', popular: false, spicy: true },
        { id: 8, name: '鮭魚親子丼', price: 380, description: '新鮮鮭魚生魚片與鮭魚卵', popular: true, spicy: false },
      ],
    },
    {
      id: 'dessert',
      name: '甜點',
      icon: '🍡',
      items: [
        { id: 9, name: '抹茶冰淇淋', price: 80, description: '京都宇治抹茶', popular: false, spicy: false },
        { id: 10, name: '黑糖蕨餅', price: 100, description: '手作蕨餅佐黑糖蜜與黃豆粉', popular: true, spicy: false },
        { id: 11, name: '焙茶布丁', price: 90, description: '濃郁焙茶風味滑順布丁', popular: false, spicy: false },
      ],
    },
    {
      id: 'drink',
      name: '飲品',
      icon: '🍵',
      items: [
        { id: 12, name: '煎茶', price: 50, description: '日本綠茶', popular: false, spicy: false },
        { id: 13, name: '梅酒蘇打', price: 150, description: '自家釀梅酒搭配氣泡水', popular: true, spicy: false },
        { id: 14, name: '可爾必思', price: 60, description: '經典乳酸飲品', popular: false, spicy: false },
      ],
    },
  ],
};

// ============================================================
// 元件:餐廳標頭
// ============================================================
const RestaurantHeader = ({ name, subtitle, description }) => (
  <header
    style={{
      background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
      color: 'white',
      textAlign: 'center',
      padding: '48px 24px',
    }}
  >
    <h1 style={{ fontSize: '2.5rem', margin: '0 0 8px', letterSpacing: '8px' }}>
      {name}
    </h1>
    <p style={{ color: '#94a3b8', margin: '0 0 4px', fontSize: '1rem' }}>{subtitle}</p>
    <p style={{ color: '#64748b', margin: 0, fontSize: '0.85rem' }}>{description}</p>
    <div
      style={{
        width: '60px',
        height: '2px',
        backgroundColor: '#c9a96e',
        margin: '20px auto 0',
      }}
    />
  </header>
);

// ============================================================
// 元件:價格標籤
// ============================================================
const PriceTag = ({ price }) => (
  <span
    style={{
      fontSize: '1.1rem',
      fontWeight: 'bold',
      color: '#c9a96e',
      whiteSpace: 'nowrap',
    }}
  >
    NT$ {price}
  </span>
);

// ============================================================
// 元件:徽章
// ============================================================
const Badge = ({ text, bgColor, textColor }) => (
  <span
    style={{
      display: 'inline-block',
      padding: '2px 8px',
      borderRadius: '4px',
      backgroundColor: bgColor,
      color: textColor,
      fontSize: '0.7rem',
      fontWeight: 'bold',
      marginLeft: '8px',
      verticalAlign: 'middle',
    }}
  >
    {text}
  </span>
);

// ============================================================
// 元件:菜單項目
// ============================================================
const MenuItem = ({ item }) => (
  <div
    style={{
      display: 'flex',
      justifyContent: 'space-between',
      alignItems: 'flex-start',
      padding: '16px 0',
      borderBottom: '1px dashed #e2e8f0',
    }}
  >
    <div style={{ flex: 1, marginRight: '16px' }}>
      <div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap' }}>
        <span style={{ fontWeight: '600', color: '#1e293b', fontSize: '1rem' }}>
          {item.name}
        </span>
        {item.popular && <Badge text="人氣" bgColor="#fef3c7" textColor="#92400e" />}
        {item.spicy && <Badge text="辣" bgColor="#fee2e2" textColor="#991b1b" />}
      </div>
      <p style={{ margin: '4px 0 0', color: '#94a3b8', fontSize: '0.85rem', lineHeight: 1.4 }}>
        {item.description}
      </p>
    </div>
    <PriceTag price={item.price} />
  </div>
);

// ============================================================
// 元件:菜單分類
// ============================================================
const MenuCategory = ({ category }) => (
  <section style={{ marginBottom: '40px' }}>
    <div
      style={{
        display: 'flex',
        alignItems: 'center',
        gap: '10px',
        marginBottom: '16px',
        paddingBottom: '12px',
        borderBottom: '2px solid #1e293b',
      }}
    >
      <span style={{ fontSize: '1.5rem' }}>{category.icon}</span>
      <h2 style={{ margin: 0, color: '#1e293b', fontSize: '1.3rem', letterSpacing: '2px' }}>
        {category.name}
      </h2>
      <span style={{ color: '#94a3b8', fontSize: '0.8rem' }}>
        ({category.items.length} 品)
      </span>
    </div>
    <div>
      {category.items.map((item) => (
        <MenuItem key={item.id} item={item} />
      ))}
    </div>
  </section>
);

// ============================================================
// 元件:餐廳頁尾
// ============================================================
const RestaurantFooter = () => (
  <footer
    style={{
      textAlign: 'center',
      padding: '32px 24px',
      backgroundColor: '#f8fafc',
      borderTop: '1px solid #e2e8f0',
      color: '#94a3b8',
      fontSize: '0.8rem',
    }}
  >
    <p style={{ margin: '0 0 4px' }}>營業時間:11:30 - 14:00 / 17:30 - 21:00</p>
    <p style={{ margin: '0 0 4px' }}>每週一公休</p>
    <p style={{ margin: 0 }}>📍 台北市大安區忠孝東路四段 123 號</p>
  </footer>
);

// ============================================================
// App:組合所有元件
// ============================================================
function App() {
  const { restaurant, categories } = menuData;

  return (
    <div
      style={{
        maxWidth: '650px',
        margin: '0 auto',
        backgroundColor: '#fff',
        minHeight: '100vh',
        fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
        boxShadow: '0 0 40px rgba(0,0,0,0.08)',
      }}
    >
      <RestaurantHeader
        name={restaurant.name}
        subtitle={restaurant.subtitle}
        description={restaurant.description}
      />

      <main style={{ padding: '32px 32px 0' }}>
        {categories.map((category) => (
          <MenuCategory key={category.id} category={category} />
        ))}
      </main>

      <RestaurantFooter />
    </div>
  );
}

export default App;

관련 글