Next.jsで高速eコマースアプリを構築する | エピソード4: カート機能と状態管理

こんにちは！前回のエピソードでは、商品詳細ページ（PDP）を構築し、SEO最適化と画像の遅延読み込みを実装しました。今回は、eコマースアプリの重要な機能であるカートを構築します。React ContextまたはZustandを使った状態管理を導入し、商品の追加、削除、数量変更をスムーズに処理します。また、localStorageを利用してカートの状態を永続化します。

## このエピソードのゴール

- カート機能を実装して商品を管理。
- Zustandを使ってグローバルな状態管理を行う。
- localStorageでカートの状態を永続化。
- Tailwind CSSでレスポンシブなカートUIを構築。

## 必要なもの

- 前回のプロジェクト（`next-ecommerce`）がセットアップ済み。
- Shopify Storefront APIが接続済み。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: Zustandのセットアップ

カートの状態を管理するために、軽量で使いやすい状態管理ライブラリ「Zustand」を使用します。まず、Zustandをインストールします：

```bash
npm install zustand
```

`src/lib/cartStore.ts`ファイルを作成し、以下のコードを追加してカートストアを定義します：

```ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface CartItem {
  id: string;
  title: string;
  variantId: string;
  price: number;
  quantity: number;
  image: string;
}

interface CartState {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  updateQuantity: (variantId: string, quantity: number) => void;
  removeItem: (variantId: string) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>()(
  persist(
    (set) => ({
      items: [],
      addItem: (item) =>
        set((state) => {
          const existingItem = state.items.find((i) => i.variantId === item.variantId);
          if (existingItem) {
            return {
              items: state.items.map((i) =>
                i.variantId === item.variantId
                  ? { ...i, quantity: i.quantity + item.quantity }
                  : i
              ),
            };
          }
          return { items: [...state.items, item] };
        }),
      updateQuantity: (variantId, quantity) =>
        set((state) => ({
          items: state.items.map((item) =>
            item.variantId === variantId ? { ...item, quantity } : item
          ),
        })),
      removeItem: (variantId) =>
        set((state) => ({
          items: state.items.filter((item) => item.variantId !== variantId),
        })),
      clearCart: () => set({ items: [] }),
    }),
    {
      name: 'cart-storage', // localStorageのキー
    }
  )
);
```

このコードは：
- カートのアイテム（ID、タイトル、価格、数量など）を管理。
- `addItem`、`updateQuantity`、`removeItem`、`clearCart`アクションを提供。
- `persist`ミドルウェアでカートの状態をlocalStorageに保存。

---

## ステップ2: カートに追加ボタンの実装

商品詳細ページ（PDP）に「カートに追加」ボタンの機能を追加します。`src/app/products/[handle]/page.tsx`を更新し、ボタンにZustandのアクションを接続します：

```tsx
import { useCartStore } from '@/lib/cartStore';
// ... 既存のインポート ...

export default function ProductPage({ product }: { product: ProductDetail }) {
  if (!product) return notFound();

  const ogImage = product.images.edges[0]?.node.url || '';
  const addItem = useCartStore((state) => state.addItem);

  const handleAddToCart = () => {
    const selectedVariant = product.variants.edges[0].node; // デフォルトで最初のバリエーションを選択
    addItem({
      id: product.id,
      title: product.title,
      variantId: selectedVariant.id,
      price: parseFloat(selectedVariant.price.amount),
      quantity: 1,
      image: product.images.edges[0]?.node.url || '',
    });
  };

  return (
    <>
      <Head>
        <title>{product.title} | Next.js eCommerce</title>
        <meta name="description" content={product.descriptionHtml.slice(0, 160)} />
        <meta property="og:title" content={product.title} />
        <meta property="og:description" content={product.descriptionHtml.slice(0, 160)} />
        <meta property="og:image" content={ogImage} />
        <meta property="og:type" content="product" />
        <meta name="twitter:card" content="summary_large_image" />
        <meta name="twitter:title" content={product.title} />
        <meta name="twitter:description" content={product.descriptionHtml.slice(0, 160)} />
        <meta name="twitter:image" content={ogImage} />
      </Head>
      <main className="container mx-auto p-4">
        <div className="grid md:grid-cols-2 gap-8">
          <div className="space-y-4">
            {product.images.edges.map((image, index) => (
              <Image
                key={index}
                src={image.node.url}
                alt={image.node.altText || product.title}
                width={500}
                height={500}
                className="w-full h-auto object-cover rounded"
                priority={index === 0}
              />
            ))}
          </div>
          <div>
            <h1 className="text-3xl font-bold mb-4">{product.title}</h1>
            <p className="text-2xl text-gray-700 mb-4">
              {parseFloat(product.priceRange.minVariantPrice.amount).toFixed(2)}{' '}
              {product.priceRange.minVariantPrice.currencyCode}
            </p>
            <div
              className="prose mb-6"
              dangerouslySetInnerHTML={{ __html: product.descriptionHtml }}
            />
            <div className="mb-6">
              <h2 className="text-lg font-semibold mb-2">バリエーション</h2>
              <select className="border p-2 rounded w-full">
                {product.variants.edges.map((variant) => (
                  <option key={variant.node.id} value={variant.node.id}>
                    {variant.node.title} - {parseFloat(variant.node.price.amount).toFixed(2)}{' '}
                    {variant.node.price.currencyCode}
                  </option>
                ))}
              </select>
            </div>
            <button
              onClick={handleAddToCart}
              className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90"
            >
              カートに追加
            </button>
          </div>
        </div>
      </main>
    </>
  );
}
```

このコードは：
- `useCartStore`フックを使って`addItem`アクションを取得。
- 「カートに追加」ボタンのクリックで商品情報をカートに追加。
- デフォルトで最初のバリエーションを選択（後でバリエーション選択機能を拡張可能）。

---

## ステップ3: カートページの構築

カートの内容を表示するページを作成します。`src/app/cart/page.tsx`ファイルを作成し、以下のコードを追加：

```tsx
import { useCartStore } from '@/lib/cartStore';
import Image from 'next/image';

export default function CartPage() {
  const { items, updateQuantity, removeItem, clearCart } = useCartStore();

  const totalPrice = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">ショッピングカート</h1>
      {items.length === 0 ? (
        <p className="text-gray-600">カートに商品がありません。</p>
      ) : (
        <div className="space-y-6">
          {items.map((item) => (
            <div key={item.variantId} className="flex items-center border p-4 rounded-lg">
              <Image
                src={item.image}
                alt={item.title}
                width={100}
                height={100}
                className="object-cover rounded"
              />
              <div className="ml-4 flex-1">
                <h2 className="text-lg font-semibold">{item.title}</h2>
                <p className="text-gray-600">
                  {item.price.toFixed(2)} {item.currencyCode || 'JPY'} x {item.quantity}
                </p>
                <div className="flex items-center mt-2">
                  <button
                    onClick={() => updateQuantity(item.variantId, Math.max(1, item.quantity - 1))}
                    className="px-2 py-1 border rounded"
                  >
                    -
                  </button>
                  <span className="mx-2">{item.quantity}</span>
                  <button
                    onClick={() => updateQuantity(item.variantId, item.quantity + 1)}
                    className="px-2 py-1 border rounded"
                  >
                    +
                  </button>
                  <button
                    onClick={() => removeItem(item.variantId)}
                    className="ml-4 text-red-500"
                  >
                    削除
                  </button>
                </div>
              </div>
            </div>
          ))}
          <div className="text-right">
            <p className="text-xl font-bold">合計: {totalPrice.toFixed(2)} JPY</p>
            <button
              onClick={clearCart}
              className="mt-4 bg-red-500 text-white px-6 py-3 rounded hover:bg-opacity-90"
            >
              カートを空にする
            </button>
          </div>
        </div>
      )}
    </main>
  );
}
```

このコードは：
- カートのアイテムを一覧表示。
- 数量変更、削除、カートクリアの機能を提供。
- Tailwind CSSでレスポンシブなUIを構築。
- 合計金額をリアルタイムで計算。

---

## ステップ4: ナビゲーションにカートリンクを追加

カートページにアクセスできるように、ナビゲーションバーを追加します。`src/components/Navbar.tsx`ファイルを作成：

```tsx
import Link from 'next/link';
import { useCartStore } from '@/lib/cartStore';

export default function Navbar() {
  const items = useCartStore((state) => state.items);
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);

  return (
    <nav className="bg-primary text-white p-4">
      <div className="container mx-auto flex justify-between items-center">
        <Link href="/" className="text-2xl font-bold">
          Next.js eCommerce
        </Link>
        <Link href="/cart" className="relative">
          カート
          {itemCount > 0 && (
            <span className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full px-2 text-sm">
              {itemCount}
            </span>
          )}
        </Link>
      </div>
    </nav>
  );
}
```

`src/app/layout.tsx`を更新してNavbarを適用：

```tsx
import './styles/globals.css';
import Navbar from '@/components/Navbar';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <Navbar />
        {children}
      </body>
    </html>
  );
}
```

これで、サイト全体にナビゲーションバーが表示され、カートのアイテム数がバッジで確認できます。

---

## ステップ5: 動作確認

1. 開発サーバーを起動（`npm run dev`）。
2. 商品詳細ページ（例: `/products/t-shirt`）で「カートに追加」をクリック。
3. `/cart`にアクセスし、以下の点を確認：
   - カートのアイテムが正しく表示される。
   - 数量変更や削除が機能する。
   - 合計金額が正確に計算される。
   - localStorageにカートデータが保存されている（開発者ツールで確認）。
4. ページをリロードしてもカート状態が維持されていることを確認。

エラーがあれば、ZustandのストアやShopify APIのレスポンスを確認してください。

---

## まとめと次のステップ

このエピソードでは、Zustandを使ってカート機能を実装し、localStorageで状態を永続化しました。商品の追加、数量変更、削除をスムーズに処理できるUIも構築しました。

次回のエピソードでは、Stripeを使った決済機能を統合します。チェックアウトページの構築やフォームバリデーションも実装しますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！