🚚

EC サイトで匿名カートからログインカートにシームレスに移行する方法 [Next.js 13 App Router]

2023/10/22に公開

はじめに

オンラインショッピングサイトでは、スムーズな買い物体験が大切であり、商品をカートに入れてから購入するまでの流れは、途切れることなくスムーズ(シームレス)であることが求められます。特に、ユーザーがサイトを訪れて商品を選び、最終的に購入に至るまでの流れは、中断や混乱のないものでなければなりません。中でも、カート機能ではユーザーがログインしていない場合でも、選択した商品を記憶し、ログイン後に簡単に購入できるようにすることが重要です。

この記事では、Next.js 13 の App Router を活用し、このようなシームレスなユーザー体験を実現する方法について解説します。ログイン前後でのカートの中身を維持し、ユーザーが途中で混乱することなく購入を完了できるよう、実装方法を共有します。

事前準備

デモプロジェクトを用意したので、そちらに沿って説明を進めます。
リポジトリもオープンソースとなっていますので、詳細について気になる方は以下のリンクからご覧ください。
https://github.com/tuboihirokidesu/ec-demo

主な技術スタックは以下のとおりです。

環境設定の手順

プロジェクトをローカルで動作させるには、環境変数を設定する必要があります。.env.sample ファイルが提供されているため、これを .env にコピーし、適切な値を設定してください。

cp .env.sample .env

次に、以下の環境変数を設定します。

DATABASE_URL:あなたのデータベース接続文字列。
GOOGLE_CLIENT_ID:Google OAuth から取得。
GOOGLE_CLIENT_SECRET:Google OAuth から取得。
NEXTAUTH_URL:http:localhost:3000
NEXTAUTH_SECRET:ランダムな安全な文字列。以下のコマンドを使用して生成できます:

openssl rand -base64 32

モックデータ(商品)の追加

テスト用の商品データを作成し、データベースに追加する方法について説明します。

seed ファイルの作成

まず、モックデータを生成するための基盤となる seed ファイルを準備します。
以下のステップに従って、seed.ts ファイルを作成し、Prisma クライアントを使用して商品データを生成します。

  1. prisma フォルダ内に seed.ts ファイルを作成します。
  2. seed.ts ファイルに以下のコードを追加します。このスクリプトは、5つのサンプル商品を生成し、各商品に名前、説明、価格を割り当てます。
prisma/seed.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

async function main() {
  // 5つの商品データを作成します
  for (let i = 0; i < 5; i++) {
    await prisma.product.create({
      data: {
        name: `Product ${i + 1}`,
        description: `Description for product ${i + 1}`,
        imageUrl: `https://unsplash.com/photos/space-gray-apple-watch-with-black-sports-band-hbTKIbuMmBI`,
        price: 1000 * (i + 1),
      },
    });
  }
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

package.jsonの更新 & シードスクリプトの実行

次に、シードスクリプトを実行するための設定をプロジェクトの package.json に追加する必要があります。これにより、コマンドラインから簡単にスクリプトを実行できるようになります。

package.json ファイルを開き、Prisma の設定を追加します。これには、スキーマファイルのパスとシードスクリプトの実行コマンドが含まれます。

{
  "name": "ec-demo",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    ...
  },
  "devDependencies": {
    ...
  },
+ "prisma": {
+   "schema": "prisma/schema.prisma",
+   "seed": "node --loader ts-node/esm prisma/seed.ts"
+ }
}

またシードスクリプトを実行するために必要な依存関係をインストールします。

pnpm i -D ts-node typescript @types/node

ターミナルで以下のコマンドを実行し、シードスクリプトを実行します。

npx prisma db seed

Prisma Studioを開くと、新しく追加された商品データがデータベースに表示されていることが確認できます。

また shadcn/ui の Card コンポーネントを利用して UI を整えておきます。

匿名状態で商品をカートに入れる実装

このセクションでは各商品に「カートに追加」ボタンを設置し、匿名状態でもそのボタンが押されたときに商品がカートに追加されるようにします。

ProductCard コンポーネントの更新

まず、各商品を表示する ProductCard コンポーネントに「カートに追加」ボタンを実装します。このボタンは、特定の商品をユーザーのカートに追加するためのトリガーとして機能します。

ProductCard.tsx
type Props = {
  product: Product;
};

export function ProductCard({ product }: Props) {
  return (
    <Card>
      <CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
        <CardTitle className='text-base font-normal'>{product.name}</CardTitle>
      </CardHeader>
      <CardContent>
        <p className='text-muted-foreground text-xs'>{product.description}</p>
      </CardContent>
      <CardFooter className='flex flex-row items-center justify-between space-y-0 pt-2'>
        <p className='text-xl font-bold'>
          {product.price} <span className='text-xs font-normal'>yen</span>
        </p>
+       <AddToCartButton
+         productId={product.id}
+         addToCartAction={addToCartAction}
+       />
      </CardFooter>
    </Card>
  );
}

AddToCartButton コンポーネントの作成

「カートに追加」ボタンは、ユーザーのインタラクションを処理する部分です。したがって、このボタンに関連するロジックを含む専用のクライアントコンポーネントを作成します。
ここではボタンが押下されたときに addProductToCard アクションを呼び出し、選択された商品をカートに追加します。また、処理中はローダーを表示し、処理が完了したらトースト通知でフィードバックを提供します。

AddToCartButton.tsx
'use client';

import { Loader2, Plus } from 'lucide-react';
import { Button } from './ui/button';
import { useTransition } from 'react';
import { useToast } from './ui/use-toast';
import { addToCartAction } from '@/app/_actions/addToCartAction';

type Props = {
  productId: string;
  addToCartAction: typeof addToCartAction;
};

export default function AddToCartButton({ productId, addToCartAction }: Props) {
  const [isPending, startTransition] = useTransition();

  const { toast } = useToast();

  const handleClick = () => {
    startTransition(async () => {
      const result = await addToCartAction(productId);

      if (result.isSuccess) {
        toast({
          variant: 'default',
          title: result.message,
        });
      } else {
        toast({
          variant: 'destructive',
          title: result.error,
        });
      }
    });
  };

  return (
    <Button onClick={handleClick} disabled={isPending}>
      <span className='text-white flex gap-2 items-center'>
        Add to cart
        {isPending ? (
          <Loader2 size={16} className='animate-spin' />
        ) : (
          <Plus size={16} />
        )}
      </span>
    </Button>
  );
}

カート追加のための Server Actions 実装

カートに商品を追加するためのロジックは、Server Actions として実装します。このアクションでは、商品が既にカートにある場合は数量を増やし、そうでない場合は新しいアイテムとしてカートに追加します。

addToCartAction.ts
'use server';

import { createCart, getCart } from '@/lib/db/cart';
import { prisma } from '@/lib/db/prisma';
import { ActionsResult } from '@/types/ActionsResult';
import { revalidatePath } from 'next/cache';

export async function addToCartAction(
  productId: string
): Promise<ActionsResult> {
  const cart = (await getCart()) ?? (await createCart());

  const articleInCart = cart.items.find((item) => item.productId === productId);

  try {
    if (articleInCart) {
       // 商品が既にカートに存在する場合、その商品の数量を +1
      await prisma.cart.update({
        where: { id: cart.id },
        data: {
          items: {
            update: {
              where: { id: articleInCart.id },
              data: { quantity: { increment: 1 } },
            },
          },
        },
      });
    } else {
      // 商品がカートに存在しない場合、新しいアイテムとしてカートに追加
      await prisma.cart.update({
        where: { id: cart.id },
        data: {
          items: {
            create: {
              productId,
              quantity: 1,
            },
          },
        },
      });
    }
    // ルートパスを再検証し、更新されたデータを即時反映
    revalidatePath('/');

    return { isSuccess: true, message: 'Product added to cart' };
  } catch (error) {
    return { isSuccess: false, error: 'Error adding product to cart' };
  }
}
カートの取得と作成についての詳細

ここでの getCart と createCart の実装は、次のセクションでにユーザーがGoogle認証を使用してログインすることを見越しています。ユーザーがログインしている場合、これらの関数はユーザーのセッション情報を使用してカートを取得または作成します。ログインしていない場合、これらの関数はローカルカートを取得または作成します。これにより、ユーザーが後でログインしたときに、匿名カートがログインカートにシームレスに移行する準備ができています。

lib/db/cart.ts
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import { prisma } from '@/lib/db/prisma';
import { Cart, CartItem, Prisma } from '@prisma/client';
import { Session, getServerSession } from 'next-auth';
import { cookies } from 'next/dist/client/components/headers';

export type CartWithProducts = Prisma.CartGetPayload<{
  include: { items: { include: { product: true } } };
}>;

export type CartItemWithProduct = Prisma.CartItemGetPayload<{
  include: { product: true };
}>;

export type ShoppingCart = CartWithProducts & {
  size: number;
  subtotal: number;
};

export async function getCart(): Promise<ShoppingCart | null> {
  const session = await getServerSession(authOptions);

  const cart: CartWithProducts | null = await findCart(session);

  if (!cart) {
    return null;
  }
  
  // カートのアイテム数と小計を計算
  return {
    ...cart,
    size: cart.items.reduce((acc, item) => acc + item.quantity, 0),
    subtotal: cart.items.reduce(
      (acc, item) => acc + item.quantity * item.product.price,
      0
    ),
  };
}

async function findCart(
  session: Session | null
): Promise<CartWithProducts | null> {
  if (session) {
    return await prisma.cart.findFirst({
      where: { userId: session.user.id },
      include: { items: { include: { product: true } } },
    });
  }

  const localCartId = cookies().get('localCartId')?.value;
  return localCartId
    ? await prisma.cart.findUnique({
        where: { id: localCartId },
        include: { items: { include: { product: true } } },
      })
    : null;
}

export async function createCart(): Promise<ShoppingCart> {
  const session = await getServerSession(authOptions);

  const newCart: Cart = await createNewCart(session);

  return {
    ...newCart,
    items: [],
    size: 0,
    subtotal: 0,
  };
}

async function createNewCart(session: Session | null): Promise<Cart> {
  if (session) {
    return await prisma.cart.create({
      data: { userId: session.user.id },
    });
  } else {
    const cart = await prisma.cart.create({
      data: {},
    });

    cookies().set('localCartId', cart.id);
    return cart;
  }
}
ショッピングカートボタンの実装についての詳細

ShoppingCartButton コンポーネントは、ユーザーのショッピングカートにある商品の総数を表示するための UI コンポーネントです。このコンポーネントは、getCart 関数を使用して取得したカート情報から、商品の総数を取得し、それを表示します。

PageHeader.tsx
import Link from 'next/link';
import ShoppingCartButton from '../ShoppingCartButton';
import { getCart } from '@/lib/db/cart';

export default async function PageHeader() {
  const cart = await getCart();

  return (
    <header className='sticky top-0 z-50 w-full border-b bg-background'>
      <div className='container flex h-16 items-center justify-between'>
        <Link href='/' className='items-center space-x-2 flex'>
          <span className='font-bold inline-block'>EC Demo</span>
          <span className='sr-only'>Home</span>
        </Link>
        <div className='flex gap-4 items-center'>
          <ShoppingCartButton cart={cart} />
        </div>
      </div>
    </header>
  );
}
ShoppingCartButton.tsx
import { ShoppingCart } from '@/lib/db/cart';
import { ShoppingCart as ShoppingCartIcon } from 'lucide-react';

type Props = {
  cart: ShoppingCart | null;
};

export default function ShoppingCartButton({ cart }: Props) {
  return (
    <div className='w-10 h-10 relative flex justify-center items-center'>
      {cart?.size && (
        <div className='absolute top-0.5 right-0.5 w-4 h-4 bg-secondary-foreground rounded-full text-white text-xs flex justify-center items-center'>
          {cart.size}
        </div>
      )}
      <ShoppingCartIcon size={24} />
    </div>
  );
}

これらの実装により、ユーザーは商品をカートに追加し、その結果がカートの総数に反映されることを確認できます。

Google 認証

このセクションでは、Next.js アプリケーションに Google 認証を統合する方法について説明します。

NextAuth.js の設定

NextAuth.js を使用して Google 認証をアプリケーションに統合するには、以下のステップに従います。

まず、NextAuth.js の設定を行います。これには、Google 認証プロバイダーの設定、セッションコールバックの設定、および Prisma アダプターの使用が含まれます。
Route Handler として作成し、以下のコードを追加します。

api/auth/[...nextauth]/route.ts
import { prisma } from '@/lib/db/prisma';
import { env } from '@/lib/env';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { PrismaClient } from '@prisma/client';
import { NextAuthOptions } from 'next-auth';
import { Adapter } from 'next-auth/adapters';
import NextAuth from 'next-auth/next';
import GoogleProvider from 'next-auth/providers/google';

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma as PrismaClient) as Adapter,
  providers: [
    GoogleProvider({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    // Prisma アダプターを使用してデータベースとのインタラクションを処理し、セッションコールバック内でユーザーIDをセッションに追加
    session({ session, user }) {
      session.user.id = user.id;
      return session;
    },
  },
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

SessionProvider コンポーネントの設定

次に、NextAuth の SessionProvider コンポーネントを使用して、アプリケーション全体でユーザーセッションを管理します。以下のコードを SessionProvider.tsx に追加します。

SessionProvider.tsx
'use client';

export { SessionProvider as NextAuthProvider } from 'next-auth/react';

また layout.tsx でこの SessionProvder をラップします。これにより、アプリケーションのどの部分からでもユーザーの認証状態にアクセスできるようになります。

layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <NextAuthProvider>
      <html lang='ja'>
        <body className={inter.className}>
          <PageHeader />
          <main className='m-auto max-w-7xl p-4'>{children}</main>
          <Toaster />
        </body>
      </html>
    </NextAuthProvider>
  );
}

認証に必要なコンポーネントの実装

まず、サーバーコンポーネントとして Header コンポーネントを作成します。このコンポーネントでは、getServerSession 関数を使用して NextAuth.js からサーバーサイドでユーザーのセッション情報を取得します。以下のコードを Header.tsx に追加します。

PageHeader.tsx

export default async function PageHeader() {
+ const session = await getServerSession(authOptions);
  const cart = await getCart();

  return (
    <header className='sticky top-0 z-50 w-full border-b bg-background'>
      <div className='container flex h-16 items-center justify-between'>
        <Link href='/' className='hidden items-center space-x-2 lg:flex'>
          <span className='hidden font-bold lg:inline-block'>EC Demo</span>
          <span className='sr-only'>Home</span>
        </Link>
        <div className='flex gap-4 items-center'>
          <ShoppingCartButton cart={cart} />
+         <UserNavButton session={session} />
        </div>
      </div>
    </header>
  );
}

次に、UserNavButton というクライアントコンポーネントを作成します。このコンポーネントは、渡されたセッション情報を基に、ユーザーがログインしているかどうかを判断し、それに応じたUIを表示します。以下のコードを UserNavButton.tsx に追加します。

UserNavButton.tsx
'use client';

import { Session } from 'next-auth';
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from './ui/dropdown-menu';
import { signIn, signOut } from 'next-auth/react';
import { LogOut, User2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar';

type Props = {
  session: Session | null;
};

export default function UserNavButton({ session }: Props) {
  const user = session?.user;

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        {user && user.image ? (
          <Avatar>
            <AvatarImage src={user.image} alt={user?.name ?? ''} />
            <AvatarFallback>{user.name?.slice(0, 2)}</AvatarFallback>
          </Avatar>
        ) : (
          <User2 className='h-6 w-6' />
        )}
      </DropdownMenuTrigger>
      <DropdownMenuContent className='w-56' align='end'>
        {user ? (
          <DropdownMenuItem onClick={() => signOut({ callbackUrl: '/' })}>
            <LogOut className='mr-2 h-4 w-4' />
            <span>Sign out</span>
          </DropdownMenuItem>
        ) : (
          <DropdownMenuItem onClick={() => signIn()}>
            <LogOut className='mr-2 h-4 w-4' />
            <span>Sign In</span>
          </DropdownMenuItem>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

これらのコンポーネントを使用することにより、ログイン状態のユーザーにはプロフィール画像とログアウトの選択肢を、未ログインのユーザーにはログインの選択肢を、それぞれ適切に提示することが可能です。

さらに、認証が完了すると、セッションコールバック内でユーザーIDがセッションに追加され、データベースのUser、Account、Sessionテーブルにデータが保存されます。

本題:EC サイトで匿名カートをログインカートにシームレスに移行する方法

前置きが長くなりましたが、ここからが本題です。
このセクションでは EC サイトでユーザーが匿名状態からログイン状態に移行した際に、カートの内容をシームレスに移行する方法について説明します。このプロセスは、ユーザーがログインすると、そのユーザーのカートに匿名で追加された商品が自動的に移行されるようにすることです。

カートのマージメソッドの作成

ユーザーがサインインした際に、cookieからlocalCartIdを取得し、そのIDに関連付けられた匿名のカートの内容をユーザーのカートにマージするメソッドを作成します。以下のコードを cart.ts に追加します。

lib/db/cart.ts
export async function mergeAnonymousCartIntoUserCart(userId: string) {
  const localCartId = cookies().get('localCartId')?.value;

  const localCart = localCartId
    ? await prisma.cart.findUnique({
        where: { id: localCartId },
        include: { items: true },
      })
    : null;

  if (!localCart) return;

  const userCart = await prisma.cart.findFirst({
    where: { userId },
    include: { items: true },
  });

  await prisma.$transaction(async (tx) => {
    if (userCart) {
      const mergedCartItems = mergeCartItems(localCart.items, userCart.items);

      await tx.cartItem.deleteMany({
        where: { cartId: userCart.id },
      });
      
      // マージしたカートアイテムを認証済みユーザーのカートに追加
      await tx.cart.update({
        where: { id: userCart.id },
        data: {
          items: {
            createMany: {
              data: mergedCartItems.map((item) => ({
                productId: item.productId,
                quantity: item.quantity,
              })),
            },
          },
        },
      });
    } else {
      // // 認証済みユーザーのカートが存在しない場合、新しいカートを作成し、匿名カートのアイテムを追加
      await tx.cart.create({
        data: {
          userId,
          items: {
            createMany: {
              data: localCart.items.map((item) => ({
                productId: item.productId,
                quantity: item.quantity,
              })),
            },
          },
        },
      });
    }
    // 匿名カートをデータベースから削除
    await tx.cart.delete({
      where: { id: localCart.id },
    });

    cookies().set('localCartId', '');
  });
}

function mergeCartItems(...cartItems: CartItem[][]): CartItem[] {
  return cartItems.reduce((acc, items) => {
    items.forEach((item) => {
      // 同じ商品が既にマージされたリストに存在するかをチェック
      const existingItem = acc.find((i) => i.productId === item.productId);
      if (existingItem) {
        existingItem.quantity += item.quantity;
      } else {
        acc.push(item);
      }
    });
    return acc;
  }, [] as CartItem[]);
}

Route Handlerの更新

ユーザーがサインインした際に mergeAnonymousCartIntoUserCart メソッドが呼び出されるように、NextAuth.js の events オプション を Route Handler に追加します。

NextAuth.js の events オプションは、特定の認証関連イベントが発生したときにトリガーされる非同期関数です。これらのイベントは、ユーザーがサインインしたり、サインアウトしたりするなど、さまざまな状況で発生します。eventsオプションは、これらのイベントに対してカスタムロジックを実行するためのハンドラーを設定するために使用されます。

例えば、signIn イベントは、ユーザーがサインイン操作を行ったときにトリガーされます。このイベントのハンドラー内で、開発者はデータベースにログを記録したり、カスタム関数を実行したりすることができます。このケースでは、signIn イベントがトリガーされたときに、匿名ユーザーのカートを認証済みユーザーのカートにマージする mergeAnonymousCartIntoUserCart 関数を呼び出しています。

詳細な情報は、NextAuth.jsの公式ドキュメントで確認できます。

app/api/[...nextauth]/route.ts
export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma as PrismaClient) as Adapter,
  providers: [
    GoogleProvider({
      clientId: env.GOOGLE_CLIENT_ID,
      clientSecret: env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    session({ session, user }) {
      session.user.id = user.id;
      return session;
    },
  },
+ events: {
+   async signIn({ user }) {
+     await mergeAnonymousCartIntoUserCart(user.id);
+   },
+ },
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

これらの変更により、ユーザーがログインすると、そのユーザーのカートに匿名で追加された商品が自動的に移行され、ログイン後のカートにはログイン前にカートに追加された商品がすべて含まれるようになります。これにより、ユーザーは購入プロセスを中断することなく、スムーズにショッピング体験 (SX??) を続けることができます。

まとめ

この記事では、Next.js 13 の App Router を利用して、ECサイトのショッピングカート機能を向上させる方法を紹介しました。要点としては、匿名ユーザーのカート情報を cookies で管理し、ユーザーがログインした際には、next-auth と Prisma を活用してその情報をユーザーアカウントにシームレスに移行する点です。

以上です!

Discussion