こんにちは!前回のエピソードでは、商品詳細ページ(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、タイトル、価格、数量など)を管理。
-
addItem
、updateQuantity
、removeItem
、clearCart
アクションを提供。 -
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: 動作確認
- 開発サーバーを起動(
npm run dev
)。 - 商品詳細ページ(例:
/products/t-shirt
)で「カートに追加」をクリック。 -
/cart
にアクセスし、以下の点を確認:- カートのアイテムが正しく表示される。
- 数量変更や削除が機能する。
- 合計金額が正確に計算される。
- localStorageにカートデータが保存されている(開発者ツールで確認)。
- ページをリロードしてもカート状態が維持されていることを確認。
エラーがあれば、ZustandのストアやShopify APIのレスポンスを確認してください。
まとめと次のステップ
このエピソードでは、Zustandを使ってカート機能を実装し、localStorageで状態を永続化しました。商品の追加、数量変更、削除をスムーズに処理できるUIも構築しました。
次回のエピソードでは、Stripeを使った決済機能を統合します。チェックアウトページの構築やフォームバリデーションも実装しますので、引き続きお楽しみに!
この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです!次回のエピソードもお楽しみに!