実践プロジェクト レッスン3
ECサイト(模擬)
商品一覧、カート、決済フローを実装しよう
プロジェクト概要
商品一覧の表示、カート機能、決済フローを持つ模擬ECサイトを構築します。 状態管理、API設計、データベース設計を実践的に学べるプロジェクトです。
商品管理
商品一覧、詳細ページ、カテゴリフィルター
カート機能
追加、数量変更、削除、合計金額計算
決済フロー
注文確認、配送先入力、注文完了
ステップ1: データベース設計
// prisma/schema.prisma
model Product {
id String @id @default(cuid())
name String
description String
price Int // 円単位(整数で管理)
image String
category String
stock Int @default(0)
cartItems CartItem[]
orderItems OrderItem[]
createdAt DateTime @default(now())
}
model Cart {
id String @id @default(cuid())
userId String @unique
items CartItem[]
updatedAt DateTime @updatedAt
}
model CartItem {
id String @id @default(cuid())
cart Cart @relation(fields: [cartId], references: [id])
cartId String
product Product @relation(fields: [productId], references: [id])
productId String
quantity Int @default(1)
@@unique([cartId, productId])
}
model Order {
id String @id @default(cuid())
userId String
items OrderItem[]
total Int
status String @default("pending")
address String
createdAt DateTime @default(now())
}
model OrderItem {
id String @id @default(cuid())
order Order @relation(fields: [orderId], references: [id])
orderId String
product Product @relation(fields: [productId], references: [id])
productId String
quantity Int
price Int // 購入時の価格を記録
}ステップ2: 商品一覧ページ
// app/products/page.tsx
import { prisma } from "@/lib/prisma";
import { ProductCard } from "./product-card";
export default async function ProductsPage({ searchParams }) {
const category = searchParams.category;
const products = await prisma.product.findMany({
where: category ? { category } : {},
orderBy: { createdAt: "desc" },
});
const categories = await prisma.product.findMany({
select: { category: true },
distinct: ["category"],
});
return (
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">商品一覧</h1>
{/* カテゴリフィルター */}
<div className="flex gap-2 mb-6 overflow-x-auto">
<a href="/products"
className={!category ? "bg-indigo-500 text-white px-4 py-2 rounded-lg" : "bg-gray-800 px-4 py-2 rounded-lg"}>
すべて
</a>
{categories.map(c => (
<a key={c.category} href={`/products?category=${c.category}`}
className={category === c.category ? "bg-indigo-500 text-white px-4 py-2 rounded-lg" : "bg-gray-800 px-4 py-2 rounded-lg"}>
{c.category}
</a>
))}
</div>
{/* 商品グリッド */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}ステップ3: カート機能
// app/actions/cart.ts
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function addToCart(productId: string) {
const userId = "demo-user"; // 本番ではセッションから取得
// カートが無ければ作成
let cart = await prisma.cart.findUnique({ where: { userId } });
if (!cart) {
cart = await prisma.cart.create({ data: { userId } });
}
// 既にカートにあれば数量を増やす
const existing = await prisma.cartItem.findUnique({
where: { cartId_productId: { cartId: cart.id, productId } },
});
if (existing) {
await prisma.cartItem.update({
where: { id: existing.id },
data: { quantity: existing.quantity + 1 },
});
} else {
await prisma.cartItem.create({
data: { cartId: cart.id, productId, quantity: 1 },
});
}
revalidatePath("/cart");
}
export async function removeFromCart(itemId: string) {
await prisma.cartItem.delete({ where: { id: itemId } });
revalidatePath("/cart");
}// app/cart/page.tsx
import { prisma } from "@/lib/prisma";
export default async function CartPage() {
const cart = await prisma.cart.findUnique({
where: { userId: "demo-user" },
include: { items: { include: { product: true } } },
});
const total = cart?.items.reduce(
(sum, item) => sum + item.product.price * item.quantity, 0
) ?? 0;
return (
<div className="max-w-2xl mx-auto p-6">
<h1 className="text-2xl font-bold mb-6">ショッピングカート</h1>
{cart?.items.map(item => (
<div key={item.id} className="flex items-center gap-4 p-4 border-b">
<img src={item.product.image} className="w-16 h-16 rounded" />
<div className="flex-1">
<p className="font-semibold">{item.product.name}</p>
<p className="text-sm text-gray-400">
{item.product.price.toLocaleString()}円 x {item.quantity}
</p>
</div>
<p className="font-bold">
{(item.product.price * item.quantity).toLocaleString()}円
</p>
</div>
))}
<div className="mt-6 text-right">
<p className="text-xl font-bold">合計: {total.toLocaleString()}円</p>
<a href="/checkout" className="inline-block mt-4 px-6 py-3 bg-indigo-500 text-white rounded-lg">
レジに進む
</a>
</div>
</div>
);
}ステップ4: 決済フロー
// app/actions/order.ts
"use server";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
export async function createOrder(formData: FormData) {
const address = formData.get("address") as string;
const userId = "demo-user";
// カート内容を取得
const cart = await prisma.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } },
});
if (!cart || cart.items.length === 0) {
throw new Error("カートが空です");
}
// 合計金額の計算
const total = cart.items.reduce(
(sum, item) => sum + item.product.price * item.quantity, 0
);
// 注文を作成(トランザクションで安全に)
await prisma.$transaction(async (tx) => {
// 注文レコードを作成
const order = await tx.order.create({
data: {
userId,
total,
address,
items: {
create: cart.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.product.price,
})),
},
},
});
// 在庫を減らす
for (const item of cart.items) {
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } },
});
}
// カートを空にする
await tx.cartItem.deleteMany({ where: { cartId: cart.id } });
return order;
});
redirect("/order-complete");
}まとめ
- ECサイトはデータベース設計が重要(商品、カート、注文の関連)
- カート操作は Server Actions で安全にデータベースを更新
- 決済処理はトランザクションで在庫とカートの整合性を保つ
- 金額は整数(円単位)で管理し、小数点の誤差を避ける
- 本番ではStripeなどの決済サービスを組み合わせて安全に実装する