1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

こんにちは!前回のエピソードでは、商品詳細ページ(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をインストールします:

npm install zustand

src/lib/cartStore.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、タイトル、価格、数量など)を管理。
  • addItemupdateQuantityremoveItemclearCartアクションを提供。
  • persistミドルウェアでカートの状態をlocalStorageに保存。

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

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

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ファイルを作成し、以下のコードを追加:

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ファイルを作成:

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を適用:

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を使った決済機能を統合します。チェックアウトページの構築やフォームバリデーションも実装しますので、引き続きお楽しみに!


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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?