TEXTBOOK SECTION / AI LEARNING

カート機能を実装する

Square API × Next.js 実践EC開発|自社ECを低コストで作る、決済・在庫・注文管理の設計と実装の「カート・注文・在庫を本格化する」より、カート機能を実装するを解説。生成AI、AI活用、DX、業務改善を実践しながら学べるオンライン教材です。

1カート・注文・在庫を本格化するEC

OVERVIEW

この節で学べること

概要を表示する
項目内容
教材名Square API × Next.js 実践EC開発|自社ECを低コストで作る、決済・在庫・注文管理の設計と実装
カート・注文・在庫を本格化する
カート機能を実装する
カテゴリEC
学習内容生成AI、AI活用、DX、業務改善を実践しながら理解するための教材です。

TABLE OF CONTENTS

目次

CONTENT

ここから

この章でわかること

この章では、ECサイトのカート機能を作ります。

第7章では、商品詳細ページからSquareの決済ページへ直接進む流れを作りました。

商品詳細ページ
↓
購入ボタン
↓
Square決済ページ

これは最小構成としては十分です。

ただし、複数の商品をまとめて購入したい場合は、カート機能が必要になります。

商品Aをカートに入れる
商品Bをカートに入れる
数量を変更する
合計金額を確認する
購入手続きへ進む

攻略本でいうと、この章は「買い物かごを手に入れるステージ」です。

ただし、カートは便利な反面、金額改ざんや数量ズレが起きやすい場所でもあります。

そのため、この章では単にカートを作るだけでなく、

安全にカートを扱う考え方も一緒に整理します。


8.1 カート機能の責務

カート機能の責務は、購入予定の商品を一時的に保存することです。

ユーザーが商品を選び、数量を調整し、購入前に内容を確認できるようにします。

カートが担当する主な役割は、次の通りです。

商品を追加する
商品を削除する
数量を変更する
小計を表示する
送料を表示する
合計金額を表示する
購入手続きへ進む

ただし、カートには重要な注意点があります。

カートに入っている金額を、そのまま決済金額として信用してはいけません。

なぜなら、ブラウザ側のデータは改ざんされる可能性があるからです。

たとえば、localStorageに次のようなデータがあるとします。

{
  "items": [
    {
      "productId": "product_001",
      "quantity": 1,
      "price": 1200
    }
  ]
}

このpriceは、ブラウザの開発者ツールなどで変更される可能性があります。

{
  "items": [
    {
      "productId": "product_001",
      "quantity": 1,
      "price": 1
    }
  ]
}

このように改ざんされた金額をそのままSquareへ送ると、危険です。

カートで保存してよいもの

カートには、基本的に次の情報だけを保存します。

商品ID
数量

価格や商品名は、表示用として持つことはあります。

しかし、決済時には必ずサーバー側の商品データから再計算します。

カートの責務とサーバーの責務

領域責務
カートユーザーが選んだ商品IDと数量を保持する
サーバー正しい商品情報、価格、在庫、送料を確認する
Square決済ページを作成し、支払いを処理する

攻略ポイント

カートは、購入予定メモです。

請求書ではありません。

カート
↓
ユーザーが選んだものを一時保存する場所

サーバー
↓
本当に請求する金額を計算する場所

この考え方を間違えないことが大切です。


8.2 localStorageで作る簡易カート

最初に一番わかりやすいのが、localStorageを使った簡易カートです。

localStorageは、ブラウザにデータを保存する仕組みです。

MDNでは、localStorageは現在のオリジンに紐づくStorageオブジェクトへアクセスするためのwindowプロパティで、保存されたデータはブラウザセッションをまたいで保持されると説明されています。(developer.mozilla.org)

簡単に言うと、ブラウザ内に小さなメモを保存できます。

商品をカートに入れる
↓
localStorageに保存する
↓
ページを再読み込みしても残る

localStorageカートのメリット

実装が簡単
DBが不要
ログインなしで使える
学習用にわかりやすい

localStorageカートの注意点

ブラウザを変えると引き継げない
別端末と同期できない
ユーザーが中身を変更できる
サーバー側から直接読めない

つまり、localStorageは便利ですが、信頼してはいけません。

最初の学習用や小規模な簡易カートには向いています。

ただし、本格的な注文処理では、サーバー側で必ず再確認します。

カートに保存する型

src/types/cart.tsを作ります。

export type CartItem = {
  productId: string;
  quantity: number;
};

export type Cart = {
  items: CartItem[];
};

カートには、商品IDと数量だけを保存します。

価格は保存しません。

localStorageを扱う関数

src/lib/cart/localStorageCart.tsを作ります。

import type { Cart, CartItem } from '@/types/cart';

const CART_STORAGE_KEY = 'square-next-ec-cart';

const EMPTY_CART: Cart = {
  items: [],
};

/**
 * 役割: ブラウザのlocalStorageからカート情報を取得する
 * 入力: なし
 * 出力: Cart型のカート情報
 */
export function getCartFromLocalStorage(): Cart {
  if (typeof window === 'undefined') {
    return EMPTY_CART;
  }

  const rawCart = window.localStorage.getItem(CART_STORAGE_KEY);

  if (!rawCart) {
    return EMPTY_CART;
  }

  try {
    const parsedCart: unknown = JSON.parse(rawCart);

    if (!isCart(parsedCart)) {
      return EMPTY_CART;
    }

    return parsedCart;
  } catch {
    return EMPTY_CART;
  }
}

/**
 * 役割: カート情報をlocalStorageに保存する
 * 入力: Cart型のカート情報
 * 出力: なし
 */
export function saveCartToLocalStorage(cart: Cart): void {
  window.localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(cart));
}

/**
 * 役割: localStorage上のカートに商品を追加する
 * 入力: 商品ID、数量
 * 出力: 更新後のCart
 */
export function addItemToLocalStorageCart(
  productId: string,
  quantity: number,
): Cart {
  const currentCart = getCartFromLocalStorage();
  const existingItem = currentCart.items.find(
    (item) => item.productId === productId,
  );

  const nextItems: CartItem[] = existingItem
    ? currentCart.items.map((item) =>
        item.productId === productId
          ? { ...item, quantity: item.quantity + quantity }
          : item,
      )
    : [...currentCart.items, { productId, quantity }];

  const nextCart: Cart = {
    items: nextItems,
  };

  saveCartToLocalStorage(nextCart);

  return nextCart;
}

/**
 * 役割: unknown型の値がCart型か確認する
 * 入力: JSON.parse後のunknown値
 * 出力: Cart型として扱えるかどうか
 */
function isCart(value: unknown): value is Cart {
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  const items = (value as { items?: unknown }).items;

  if (!Array.isArray(items)) {
    return false;
  }

  return items.every(isCartItem);
}

/**
 * 役割: unknown型の値がCartItem型か確認する
 * 入力: unknown値
 * 出力: CartItem型として扱えるかどうか
 */
function isCartItem(value: unknown): value is CartItem {
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  const candidate = value as {
    productId?: unknown;
    quantity?: unknown;
  };

  return (
    typeof candidate.productId === 'string' &&
    typeof candidate.quantity === 'number' &&
    Number.isInteger(candidate.quantity) &&
    candidate.quantity > 0
  );
}

攻略ポイント

localStorageカートは、最初に動かすには便利です。

ただし、信頼できる情報源ではありません。

localStorage
↓
表示用・一時保存用

サーバー側
↓
決済金額を決める場所

8.3 Cookieで管理するカート

次に、Cookieでカートを管理する方法を見ていきます。

Cookieは、ブラウザに保存され、リクエスト時にサーバーへ送られるデータです。

localStorageと違い、サーバー側でも読み取りやすい点が特徴です。

Next.jsでは、cookies関数を使ってCookieを扱えます。公式ドキュメントでは、Server Actionの中でcookies APIを使ってCookieの取得、設定、削除ができると説明されています。また、Cookieを設定・削除した後、Next.jsは現在のページとレイアウトをサーバー側で再レンダリングし、UIに反映できると説明されています。(nextjs.org, nextjs.org)

Cookieカートのメリット

サーバー側で読み取れる
未ログインユーザーにも使える
Server Actionsと相性がよい
決済前の再計算に使いやすい

Cookieカートの注意点

保存できる容量に制限がある
大量の商品情報を入れない
秘密情報を入れない
価格を信用しない

Cookieには、商品IDと数量だけを入れるのが基本です。

商品名、価格、説明文、画像URLなどを大量に入れるのは避けます。

Cookieに保存するカート

Cookieに直接JSONを保存する場合、次のような形にします。

{
  "items": [
    {
      "productId": "product_001",
      "quantity": 2
    }
  ]
}

Cookieカート用の定数

src/lib/cart/cookieCart.tsを作ります。

import type { Cart, CartItem } from '@/types/cart';

export const CART_COOKIE_NAME = 'square-next-ec-cart';

export const EMPTY_CART: Cart = {
  items: [],
};

/**
 * 役割: カート情報をCookie保存用の文字列へ変換する
 * 入力: Cart型のカート情報
 * 出力: Cookieに保存できるJSON文字列
 */
export function serializeCart(cart: Cart): string {
  return JSON.stringify(cart);
}

/**
 * 役割: Cookie文字列からCart型のカート情報を復元する
 * 入力: Cookieに保存された文字列
 * 出力: Cart型のカート情報
 */
export function parseCartCookie(value: string | undefined): Cart {
  if (!value) {
    return EMPTY_CART;
  }

  try {
    const parsedValue: unknown = JSON.parse(value);

    if (!isCart(parsedValue)) {
      return EMPTY_CART;
    }

    return parsedValue;
  } catch {
    return EMPTY_CART;
  }
}

/**
 * 役割: カートに商品を追加したCartを返す
 * 入力: 現在のCart、商品ID、数量
 * 出力: 商品追加後のCart
 */
export function addItemToCart(
  cart: Cart,
  productId: string,
  quantity: number,
): Cart {
  const existingItem = cart.items.find((item) => item.productId === productId);

  const nextItems: CartItem[] = existingItem
    ? cart.items.map((item) =>
        item.productId === productId
          ? { ...item, quantity: item.quantity + quantity }
          : item,
      )
    : [...cart.items, { productId, quantity }];

  return {
    items: nextItems,
  };
}

/**
 * 役割: unknown型の値がCart型か確認する
 * 入力: JSON.parse後のunknown値
 * 出力: Cart型として扱えるかどうか
 */
function isCart(value: unknown): value is Cart {
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  const items = (value as { items?: unknown }).items;

  if (!Array.isArray(items)) {
    return false;
  }

  return items.every(isCartItem);
}

/**
 * 役割: unknown型の値がCartItem型か確認する
 * 入力: unknown値
 * 出力: CartItem型として扱えるかどうか
 */
function isCartItem(value: unknown): value is CartItem {
  if (typeof value !== 'object' || value === null) {
    return false;
  }

  const candidate = value as {
    productId?: unknown;
    quantity?: unknown;
  };

  return (
    typeof candidate.productId === 'string' &&
    typeof candidate.quantity === 'number' &&
    Number.isInteger(candidate.quantity) &&
    candidate.quantity > 0
  );
}

攻略ポイント

Cookieカートは、localStorageよりもサーバー処理とつなげやすいです。

localStorage
↓
ブラウザ中心

Cookie
↓
サーバーでも読みやすい

最小構成ではlocalStorageでも構いません。

ただし、Next.jsのServer Actionsやサーバー側決済処理と自然につなげたい場合は、Cookieカートが扱いやすくなります。


8.4 Server Actionsでカートを操作する

Server Actionsを使うと、フォーム送信やデータ変更処理をサーバー側で実行できます。

Next.js公式ドキュメントでは、Server Actionsはサーバー上で実行される非同期関数で、フォーム送信やデータ変更に使えると説明されています。(nextjs.org)

カート操作では、次のような処理に使えます。

商品を追加する
商品を削除する
数量を変更する
カートを空にする

ここでは、CookieカートをServer Actionで操作します。

Server Actionを作る

src/actions/cartActions.tsを作ります。

'use server';

import { cookies } from 'next/headers';
import {
  addItemToCart,
  CART_COOKIE_NAME,
  parseCartCookie,
  serializeCart,
} from '@/lib/cart/cookieCart';

/**
 * 役割: カートに商品を追加し、Cookieに保存する
 * 入力: 商品IDと数量を含むFormData
 * 出力: なし
 */
export async function addToCartAction(formData: FormData): Promise<void> {
  const productIdValue = formData.get('productId');
  const quantityValue = formData.get('quantity');

  if (typeof productIdValue !== 'string') {
    return;
  }

  const quantity =
    typeof quantityValue === 'string' ? Number(quantityValue) : 1;

  if (!Number.isInteger(quantity) || quantity <= 0) {
    return;
  }

  const cookieStore = await cookies();
  const currentCart = parseCartCookie(
    cookieStore.get(CART_COOKIE_NAME)?.value,
  );

  const nextCart = addItemToCart(currentCart, productIdValue, quantity);

  cookieStore.set(CART_COOKIE_NAME, serializeCart(nextCart), {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
  });
}

商品詳細ページから呼び出す

購入ボタンとは別に、「カートに入れる」フォームを作ります。

import { addToCartAction } from '@/actions/cartActions';

type AddToCartFormProps = {
  productId: string;
  isAvailable: boolean;
};

/**
 * 役割: 商品詳細ページでカート追加フォームを表示する
 * 入力: 商品ID、販売可能状態
 * 出力: Server Actionへ送信するカート追加フォーム
 */
export function AddToCartForm({
  productId,
  isAvailable,
}: AddToCartFormProps) {
  return (
    <form action={addToCartAction} className="space-y-3">
      <input type="hidden" name="productId" value={productId} />

      <label className="block space-y-2">
        <span className="text-sm font-semibold text-gray-700">数量</span>
        <input
          type="number"
          name="quantity"
          min="1"
          defaultValue="1"
          disabled={!isAvailable}
          className="w-24 rounded-md border border-gray-300 px-3 py-2"
        />
      </label>

      <button
        type="submit"
        disabled={!isAvailable}
        className="w-full rounded-md border border-gray-900 px-6 py-3 text-sm font-semibold text-gray-900 disabled:cursor-not-allowed disabled:border-gray-300 disabled:text-gray-300"
      >
        {isAvailable ? 'カートに入れる' : '売り切れ'}
      </button>
    </form>
  );
}

攻略ポイント

Server Actionsは、ユーザー操作をサーバー側へ渡す道具です。

フォーム送信
↓
Server Action
↓
Cookieを更新
↓
ページを再表示

カートをCookieで管理する場合、Server Actionsと相性が良いです。


8.5 商品追加・削除・数量変更

カートには、追加だけでなく、削除と数量変更が必要です。

最低限必要な操作は、次の3つです。

商品追加
商品削除
数量変更

削除関数を作る

src/lib/cart/cookieCart.tsに追加します。

/**
 * 役割: カートから指定した商品を削除する
 * 入力: 現在のCart、商品ID
 * 出力: 商品削除後のCart
 */
export function removeItemFromCart(cart: Cart, productId: string): Cart {
  return {
    items: cart.items.filter((item) => item.productId !== productId),
  };
}

数量変更関数を作る

/**
 * 役割: カート内商品の数量を変更する
 * 入力: 現在のCart、商品ID、変更後数量
 * 出力: 数量変更後のCart
 */
export function updateCartItemQuantity(
  cart: Cart,
  productId: string,
  quantity: number,
): Cart {
  if (!Number.isInteger(quantity) || quantity <= 0) {
    return removeItemFromCart(cart, productId);
  }

  return {
    items: cart.items.map((item) =>
      item.productId === productId ? { ...item, quantity } : item,
    ),
  };
}

Server Actionsを追加する

src/actions/cartActions.tsに追加します。

'use server';

import { cookies } from 'next/headers';
import {
  CART_COOKIE_NAME,
  parseCartCookie,
  removeItemFromCart,
  serializeCart,
  updateCartItemQuantity,
} from '@/lib/cart/cookieCart';

/**
 * 役割: カートから商品を削除し、Cookieに保存する
 * 入力: 商品IDを含むFormData
 * 出力: なし
 */
export async function removeFromCartAction(
  formData: FormData,
): Promise<void> {
  const productIdValue = formData.get('productId');

  if (typeof productIdValue !== 'string') {
    return;
  }

  const cookieStore = await cookies();
  const currentCart = parseCartCookie(
    cookieStore.get(CART_COOKIE_NAME)?.value,
  );

  const nextCart = removeItemFromCart(currentCart, productIdValue);

  cookieStore.set(CART_COOKIE_NAME, serializeCart(nextCart), {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
  });
}

/**
 * 役割: カート内商品の数量を変更し、Cookieに保存する
 * 入力: 商品IDと数量を含むFormData
 * 出力: なし
 */
export async function updateCartQuantityAction(
  formData: FormData,
): Promise<void> {
  const productIdValue = formData.get('productId');
  const quantityValue = formData.get('quantity');

  if (typeof productIdValue !== 'string' || typeof quantityValue !== 'string') {
    return;
  }

  const quantity = Number(quantityValue);

  if (!Number.isInteger(quantity)) {
    return;
  }

  const cookieStore = await cookies();
  const currentCart = parseCartCookie(
    cookieStore.get(CART_COOKIE_NAME)?.value,
  );

  const nextCart = updateCartItemQuantity(
    currentCart,
    productIdValue,
    quantity,
  );

  cookieStore.set(CART_COOKIE_NAME, serializeCart(nextCart), {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
  });
}

攻略ポイント

数量変更では、0以下になったら削除扱いにするとわかりやすいです。

数量 1以上
↓
数量を更新

数量 0以下
↓
カートから削除

ただし、実務では最大購入数も設定します。

たとえば、1回の注文で購入できる数量を10個までにするなどです。


8.6 小計、送料、合計金額の計算

カート画面では、小計、送料、合計金額を表示します。

ただし、ここでも重要なのは、

価格はカートからではなく商品マスターから取得することです。

金額計算の流れ

カートに入っている商品IDを取得
↓
商品マスターから正しい価格を取得
↓
数量をかける
↓
小計を出す
↓
送料を計算する
↓
合計金額を出す

計算用の型を作る

src/types/cart.tsを拡張します。

import type { Product } from '@/types/product';

export type CartItem = {
  productId: string;
  quantity: number;
};

export type Cart = {
  items: CartItem[];
};

export type ResolvedCartItem = {
  product: Product;
  quantity: number;
  lineTotal: number;
};

export type CartSummary = {
  items: ResolvedCartItem[];
  subtotal: number;
  shippingFee: number;
  total: number;
};

金額計算関数を作る

src/lib/cart/calculateCart.tsを作ります。

import { products } from '@/data/products';
import type { Cart, CartSummary, ResolvedCartItem } from '@/types/cart';

const FREE_SHIPPING_THRESHOLD = 5000;
const DEFAULT_SHIPPING_FEE = 700;

/**
 * 役割: カート情報をもとに商品情報と金額を計算する
 * 入力: 商品IDと数量を持つCart
 * 出力: 商品情報、小計、送料、合計金額を含むCartSummary
 */
export function calculateCartSummary(cart: Cart): CartSummary {
  const resolvedItems: ResolvedCartItem[] = cart.items.flatMap((item) => {
    const product = products.find(
      (candidate) => candidate.id === item.productId,
    );

    if (!product) {
      return [];
    }

    const lineTotal = product.price * item.quantity;

    return [
      {
        product,
        quantity: item.quantity,
        lineTotal,
      },
    ];
  });

  const subtotal = resolvedItems.reduce(
    (total, item) => total + item.lineTotal,
    0,
  );

  const shippingFee =
    subtotal >= FREE_SHIPPING_THRESHOLD || subtotal === 0
      ? 0
      : DEFAULT_SHIPPING_FEE;

  return {
    items: resolvedItems,
    subtotal,
    shippingFee,
    total: subtotal + shippingFee,
  };
}

送料の考え方

送料は、ECの設計によって変わります。

一律送料
購入金額に応じて送料無料
地域別送料
商品別送料
クール便
店頭受け取り

最初は、一律送料から始めるのがわかりやすいです。

5,000円以上
↓
送料無料

5,000円未満
↓
送料700円

攻略ポイント

小計や合計金額は、表示用と決済用で考え方を分けます。

カート画面
↓
ユーザーに目安を表示する

決済作成時
↓
サーバー側で再計算する

表示金額がそのまま決済金額になるのではありません。

決済直前にサーバーで再計算します。


8.7 型安全なカートデータ設計

カートは、ECの中でもデータが崩れやすい場所です。

数量が文字列になったり、存在しない商品IDが入ったり、商品が削除済みだったりします。

そのため、型安全に設計します。

カートの基本型

export type CartItem = {
  productId: string;
  quantity: number;
};

export type Cart = {
  items: CartItem[];
};

この型は、あくまで「ユーザーが選んだもの」です。

商品情報を解決した型

export type ResolvedCartItem = {
  product: Product;
  quantity: number;
  lineTotal: number;
};

これは、商品マスターから正しい商品情報を取得した後の型です。

合計金額まで計算した型

export type CartSummary = {
  items: ResolvedCartItem[];
  subtotal: number;
  shippingFee: number;
  total: number;
};

このように、段階ごとに型を分けます。

Cart
↓
商品IDと数量だけ

ResolvedCartItem
↓
商品情報を解決済み

CartSummary
↓
小計・送料・合計まで計算済み

なぜ型を分けるのか

すべてを1つの型に詰め込むと、どの情報が信用できるのかわからなくなります。

ユーザーが持っている情報
↓
信用しない

サーバーが商品マスターから取った情報
↓
信用する

サーバーが計算した金額
↓
決済に使う

この違いを型で分けます。

攻略ポイント

型は、情報の信頼度を分けるためにも使います。

未確認のカート
↓
Cart

確認済みの商品付きカート
↓
ResolvedCartItem

決済直前の計算結果
↓
CartSummary

ECでは、この分離がとても大切です。


8.8 不正な金額改ざんを防ぐ

カート機能で最も重要なのが、金額改ざん対策です。

ユーザーのブラウザに保存されたデータは、変更される可能性があります。

localStorageもCookieも、完全には信用できません。

特にlocalStorageは、ブラウザの開発者ツールから簡単に変更できます。

MDNでは、Web Storage APIはブラウザがキーと値のペアを保存できる仕組みとして説明されています。これは便利ですが、サーバー側の安全な保存場所ではありません。(developer.mozilla.org)

やってはいけない設計

カート内のpriceを読む
↓
そのまま合計金額を計算する
↓
Squareへ送る

これは危険です。

ユーザーが価格を書き換えられるからです。

よい設計

カートから商品IDと数量だけ受け取る
↓
サーバー側の商品マスターを参照する
↓
正しい価格を取得する
↓
合計金額を計算する
↓
Squareへ送る

つまり、決済時に信頼するのはカートの金額ではありません。

サーバー側の商品データです。

数量も確認する

価格だけでなく、数量も確認します。

数量が整数か
数量が1以上か
最大購入数を超えていないか
在庫数を超えていないか
販売中の商品か

決済前チェックリスト

Square決済リンクを作る直前に、次の確認を行います。

商品IDが存在する
商品が販売中
数量が正しい
在庫が足りている
価格はサーバー側から取得
送料もサーバー側で計算
合計金額をサーバー側で確定

攻略ポイント

カートは、ユーザーの希望を保存する場所です。

請求金額を決める場所ではありません。

ユーザーの希望
↓
カート

請求金額
↓
サーバーで計算

決済
↓
Squareへ正しい金額を送る

これが、金額改ざんを防ぐ基本です。


8.9 クライアント表示価格とサーバー計算価格を分ける

カート画面では、ユーザーに小計や合計金額を表示します。

この表示は大切です。

購入前にいくらかかるのかがわからないと、ユーザーは不安になります。

ただし、画面に表示されている金額を、そのまま決済金額として使ってはいけません。

クライアント表示価格

クライアント表示価格は、ユーザーに見せるための価格です。

商品単価
数量
小計
送料
合計

これは購入前の確認に使います。

サーバー計算価格

サーバー計算価格は、実際にSquareへ送る価格です。

商品IDを受け取る
数量を受け取る
商品マスターから価格を取得
送料を計算
合計金額を確定
Square Payment Linkを作る

同じに見えても、役割が違う

理想的には、クライアント表示価格とサーバー計算価格は同じになります。

しかし、信頼度は違います。

クライアント表示価格
↓
ユーザーに見せる目安

サーバー計算価格
↓
実際に請求する金額

カートから決済へ進むときの流れ

カート画面で合計金額を見る
↓
購入手続きへ進む
↓
サーバー側でカートを読み取る
↓
商品マスターから価格を再取得する
↓
送料を再計算する
↓
Square決済リンクを作る

この流れなら、ブラウザ側の価格が改ざんされても、決済金額には影響しません。

攻略ポイント

ユーザーに見せる価格と、Squareへ送る価格は、必ず別物として考えます。

見せる価格
↓
UXのため

請求する価格
↓
サーバー側で確定

この分離ができていれば、カート機能はかなり安全になります。


カートページの完成イメージ

ここでは、Cookieカートを読み取り、商品情報と金額を表示するカートページを作ります。

src/app/cart/page.tsxを作ります。

import { cookies } from 'next/headers';
import Link from 'next/link';
import type { Metadata } from 'next';
import {
  removeFromCartAction,
  updateCartQuantityAction,
} from '@/actions/cartActions';
import { CART_COOKIE_NAME, parseCartCookie } from '@/lib/cart/cookieCart';
import { calculateCartSummary } from '@/lib/cart/calculateCart';
import { formatPrice } from '@/utils/formatPrice';

export const metadata: Metadata = {
  title: 'カート|Square Next EC',
  description: 'カートに入れた商品を確認できます。',
};

/**
 * 役割: Cookieに保存されたカート情報を読み取り、商品と合計金額を表示する
 * 入力: Cookieに保存されたカート情報
 * 出力: 商品一覧、数量変更、削除、小計、送料、合計金額を含むカートページ
 */
export default async function CartPage() {
  const cookieStore = await cookies();
  const cart = parseCartCookie(cookieStore.get(CART_COOKIE_NAME)?.value);
  const cartSummary = calculateCartSummary(cart);

  return (
    <main className="mx-auto max-w-5xl px-4 py-12">
      <div className="mb-8 space-y-3">
        <p className="text-sm font-semibold text-gray-500">Cart</p>
        <h1 className="text-3xl font-bold tracking-tight text-gray-900">
          カート
        </h1>
        <p className="text-sm leading-7 text-gray-600">
          購入予定の商品と数量をご確認ください。
        </p>
      </div>

      {cartSummary.items.length === 0 ? (
        <div className="rounded-xl border border-gray-200 p-8 text-center">
          <p className="text-gray-700">カートに商品が入っていません。</p>

          <Link
            href="/products"
            className="mt-6 inline-flex rounded-md bg-black px-5 py-3 text-sm font-semibold text-white"
          >
            商品一覧へ戻る
          </Link>
        </div>
      ) : (
        <div className="grid gap-8 lg:grid-cols-[1fr_320px]">
          <div className="space-y-4">
            {cartSummary.items.map((item) => (
              <div
                key={item.product.id}
                className="rounded-xl border border-gray-200 p-4"
              >
                <div className="flex items-start justify-between gap-4">
                  <div className="space-y-2">
                    <h2 className="font-semibold text-gray-900">
                      {item.product.name}
                    </h2>

                    <p className="text-sm text-gray-600">
                      {formatPrice(item.product.price, item.product.currency)}
                    </p>

                    <p className="text-sm font-semibold text-gray-900">
                      小計: {formatPrice(item.lineTotal, item.product.currency)}
                    </p>
                  </div>

                  <form action={removeFromCartAction}>
                    <input
                      type="hidden"
                      name="productId"
                      value={item.product.id}
                    />
                    <button
                      type="submit"
                      className="text-sm font-semibold text-gray-500"
                    >
                      削除
                    </button>
                  </form>
                </div>

                <form
                  action={updateCartQuantityAction}
                  className="mt-4 flex items-center gap-3"
                >
                  <input
                    type="hidden"
                    name="productId"
                    value={item.product.id}
                  />

                  <label className="text-sm font-semibold text-gray-700">
                    数量
                  </label>

                  <input
                    type="number"
                    name="quantity"
                    min="1"
                    defaultValue={item.quantity}
                    className="w-24 rounded-md border border-gray-300 px-3 py-2"
                  />

                  <button
                    type="submit"
                    className="rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-900"
                  >
                    更新
                  </button>
                </form>
              </div>
            ))}
          </div>

          <aside className="h-fit rounded-xl border border-gray-200 p-6">
            <h2 className="text-lg font-bold text-gray-900">注文内容</h2>

            <dl className="mt-6 space-y-4 text-sm">
              <div className="flex justify-between">
                <dt className="text-gray-500">小計</dt>
                <dd className="font-semibold text-gray-900">
                  {formatPrice(cartSummary.subtotal, 'JPY')}
                </dd>
              </div>

              <div className="flex justify-between">
                <dt className="text-gray-500">送料</dt>
                <dd className="font-semibold text-gray-900">
                  {formatPrice(cartSummary.shippingFee, 'JPY')}
                </dd>
              </div>

              <div className="border-t border-gray-200 pt-4">
                <div className="flex justify-between">
                  <dt className="text-gray-900">合計</dt>
                  <dd className="text-lg font-bold text-gray-900">
                    {formatPrice(cartSummary.total, 'JPY')}
                  </dd>
                </div>
              </div>
            </dl>

            <Link
              href="/checkout"
              className="mt-6 flex w-full justify-center rounded-md bg-black px-5 py-3 text-sm font-semibold text-white"
            >
              購入手続きへ進む
            </Link>
          </aside>
        </div>
      )}
    </main>
  );
}

このページでは、Cookieに保存された商品IDと数量を読み取り、サーバー側で商品情報を解決して金額を計算しています。


この章のまとめ

この章では、カート機能を実装しました。

カートの責務は、ユーザーが購入予定の商品を一時的に保存することです。

ただし、カートは請求金額を決める場所ではありません。

この章で扱った内容は、次の通りです。

カート機能の責務を整理する
localStorageで簡易カートを作る
Cookieでサーバー側から読めるカートを作る
Server Actionsでカートを操作する
商品追加・削除・数量変更を実装する
小計、送料、合計金額を計算する
型安全なカートデータを設計する
金額改ざんを防ぐ
クライアント表示価格とサーバー計算価格を分ける

特に重要なのは、次の考え方です。

カートに保存する
↓
商品IDと数量

サーバーで再計算する
↓
商品名、価格、送料、合計金額

Squareへ送る
↓
サーバーで確定した金額

これにより、ブラウザ側の価格が改ざんされても、決済金額には影響しにくい設計になります。


次の章でやること

次の章では、注文データを設計します。

カートは、購入予定の商品を保存する場所でした。

次は、そのカートから正式な注文を作ります。

注文ID、注文ステータス、注文明細、購入者情報、配送先情報などを整理し、Square決済と自前DBをどうつなげるかを攻略していきます。

FAQ

よくある質問

カート機能を実装するは医療関係者向けだけの内容ですか。
医療分野の例が含まれる場合もありますが、医療関係者だけに限定した内容ではありません。生成AI、AI活用、DX、業務改善、プロトタイプ開発など、一般的なAI学習の事例として読める内容です。
AI初心者でも読めますか。
はい。AIをこれから学ぶ方、数学が苦手な方、仕事でAIを使いたい方にも読み進めやすいように、教材の章と節の流れに沿って整理しています。
サムネイル画像は必ず表示されますか。
はい。教材にcoverUrlが設定されている場合はその画像を表示し、未設定の場合は代替サムネイル画像を表示します。
Square API × Next.js 実践EC開発|自社ECを低コストで作る、決済・在庫・注文管理の設計と実装のほかの章も読めますか。
はい。教材トップから章立てを確認でき、前後の節へもページ下部のナビゲーションから移動できます。