2
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とSupabaseでオンライ学習プラットフォーム(LMS)を構築する | エピソード6: Stripeによる決済システムの統合

Posted at

こんにちは!前回のエピソードでは、Supabase Storageを使ってビデオレッスンを管理・配信し、React Playerで再生機能を実装しました。今回は、Stripeを統合してコース購入機能を実装します。チェックアウトページを構築し、Next.jsのAPIルートでStripeセッションを処理、購入情報をSupabaseのpurchasesテーブルに保存します。これで、ユーザーがコースを購入してアクセスできるようになります!

このエピソードのゴール

  • Stripe Checkoutを統合して決済処理を実装。
  • Next.jsのAPIルートでStripeセッションを作成。
  • チェックアウトページをReact Hook Formで構築。
  • 購入情報をSupabaseのpurchasesテーブルに保存。

必要なもの

  • 前回のプロジェクト(next-lms)がセットアップ済み。
  • Supabaseプロジェクト(courseslessonsテーブル設定済み)。
  • Stripeアカウント(テストモードで十分)。
  • stripe@stripe/stripe-jsパッケージ。
  • 基本的なTypeScript、React、Next.jsの知識。

ステップ1: Stripeのセットアップ

Stripeアカウントを設定し、必要なパッケージをインストールします。

  1. Stripeアカウントの作成

    • Stripeダッシュボードでアカウントを作成。
    • テストモードでAPIキー(公開キーとシークレットキー)を取得。
    • 「Products」セクションでコースを登録(手動またはプログラムで同期)。
  2. パッケージのインストール
    Stripe関連のパッケージをインストール:

npm install stripe @stripe/stripe-js
  1. 環境変数の設定
    .env.localにStripeのキーを追加:
STRIPE_SECRET_KEY=あなたのStripeシークレットキー
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=あなたのStripe公開キー

ステップ2: purchasesテーブルの設計

購入情報を保存するテーブルを作成します。

  1. Supabaseダッシュボードでテーブル作成
    • Supabaseダッシュボードの「Table Editor」→「New Table」でpurchasesテーブルを作成。
    • 以下のカラムを定義:
カラム名 タイプ 説明
id uuid 主キー(自動生成)
user_id uuid 購入者のユーザーID
course_id uuid 購入したコースID
stripe_session_id text StripeセッションID
amount numeric 支払い金額
status text 購入ステータス(例: completed)
created_at timestamptz 作成日時(自動生成)
  • 外部キー制約を追加:

    ALTER TABLE purchases
    ADD CONSTRAINT fk_user
    FOREIGN KEY (user_id)
    REFERENCES auth.users (id)
    ON DELETE CASCADE;
    
    ALTER TABLE purchases
    ADD CONSTRAINT fk_course
    FOREIGN KEY (course_id)
    REFERENCES courses (id)
    ON DELETE CASCADE;
    
  • 「Enable Row Level Security (RLS)」を有効化し、ポリシーを設定:

    • Select Policy: 自分の購入履歴のみ閲覧(auth.uid() = user_id)。
    • Insert Policy: 認証済みユーザーのみ(auth.uid() = user_id)。

ステップ3: StripeクライアントとAPIルートの作成

Stripeセッションを処理するAPIルートを構築します。

  1. Stripeクライアントの初期化
    src/lib/stripe.tsを作成:
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});
  1. チェックアウトAPIルートの作成
    src/app/api/checkout/route.tsを作成:
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  const { courseId } = await request.json();

  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: '認証が必要です' }, { status: 401 });
  }

  const course = await supabase
    .from('courses')
    .select('id, title, price')
    .eq('id', courseId)
    .single();

  if (!course.data) {
    return NextResponse.json({ error: 'コースが見つかりません' }, { status: 404 });
  }

  try {
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price_data: {
            currency: 'jpy',
            product_data: {
              name: course.data.title,
            },
            unit_amount: course.data.price,
          },
          quantity: 1,
        },
      ],
      mode: 'payment',
      success_url: `${request.headers.get('origin')}/courses/${courseId}?success=true`,
      cancel_url: `${request.headers.get('origin')}/courses/${courseId}?canceled=true`,
      metadata: {
        userId: user.id,
        courseId: course.data.id,
      },
    });

    await supabase
      .from('purchases')
      .insert({
        user_id: user.id,
        course_id: course.data.id,
        stripe_session_id: session.id,
        amount: course.data.price,
        status: 'pending',
      });

    return NextResponse.json({ url: session.url });
  } catch (error) {
    console.error('Stripeエラー:', error);
    return NextResponse.json({ error: '決済セッションの作成に失敗しました' }, { status: 500 });
  }
}

このコードは:

  • 認証済みユーザーのみを許可。
  • コース情報を取得し、Stripe Checkoutセッションを作成。
  • 購入情報をpurchasesテーブルに保存(pendingステータス)。
  • 成功/キャンセル時にCDPにリダイレクト。

ステップ4: チェックアウトページの構築

チェックアウトページをReact Hook Formで構築します。src/app/checkout/page.tsxを作成:

import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';

type CheckoutForm = {
  courseId: string;
};

export default async function CheckoutPage() {
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return (
      <main className="container mx-auto p-4">
        <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
        <p className="text-red-500">購入するにはログインしてください。</p>
        <a href="/login" className="text-primary hover:underline">ログイン</a>
      </main>
    );
  }

  return (
    <main className="container mx-auto p-4 max-w-md">
      <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
      <CheckoutForm />
    </main>
  );
}

function CheckoutForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<CheckoutForm>();
  const router = useRouter();
  const searchParams = useSearchParams();
  const courseId = searchParams.get('courseId');

  const onSubmit = async (data: CheckoutForm) => {
    const response = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ courseId: data.courseId }),
    });

    const result = await response.json();
    if (result.url) {
      router.push(result.url);
    } else {
      alert(result.error || '決済処理に失敗しました');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">コースID</label>
        <input
          type="text"
          defaultValue={courseId || ''}
          {...register('courseId', { required: 'コースIDは必須です' })}
          className="w-full border p-2 rounded"
        />
        {errors.courseId && <p className="text-red-500 text-sm">{errors.courseId.message}</p>}
      </div>
      <button type="submit" className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 w-full">
        決済に進む
      </button>
    </form>
  );
}

このコードは:

  • 認証済みユーザーのみを許可。
  • React Hook FormでコースIDを入力(クエリパラメータから初期値設定)。
  • /api/checkoutにリクエストを送信し、Stripe Checkoutにリダイレクト。

ステップ5: 購入ステータスの更新

StripeのWebhookを使って購入完了を処理します(簡略化のため、CDPで手動確認)。

  1. CDPの更新
    src/app/courses/[id]/page.tsxを更新して購入ステータスを確認:
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getCourseById, getLessonsByCourseId, getSignedVideoUrl, Course, Lesson } from '@/lib/supabase';
import { Metadata } from 'next';
import ReactPlayer from 'react-player';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';

export async function generateStaticParams() {
  const courses = await getAllCourseIds();
  return courses.map((course) => ({
    id: course.id,
  }));
}

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const course = await getCourseById(params.id);
  if (!course) return {};

  return {
    title: `${course.title} | Next.js LMS`,
    description: course.description,
    openGraph: {
      title: course.title,
      description: course.description,
      images: [{ url: course.image_url || 'https://via.placeholder.com/600x400' }],
    },
    twitter: {
      card: 'summary_large_image',
      title: course.title,
      description: course.description,
      images: [course.image_url || 'https://via.placeholder.com/600x400'],
    },
  };
}

export default async function CourseDetailPage({ params, searchParams }: { params: { id: string }, searchParams: { success?: string, canceled?: string } }) {
  const course = await getCourseById(params.id);
  const lessons = await getLessonsByCourseId(params.id);
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!course) {
    notFound();
  }

  const { data: purchase } = await supabase
    .from('purchases')
    .select('*')
    .eq('user_id', user?.id || '')
    .eq('course_id', course.id)
    .single();

  const isPurchased = purchase && purchase.status === 'completed';

  const lessonsWithSignedUrls = isPurchased
    ? await Promise.all(
        lessons.map(async (lesson) => ({
          ...lesson,
          signedUrl: await getSignedVideoUrl(lesson.video_path),
        }))
      )
    : [];

  if (searchParams.success) {
    await supabase
      .from('purchases')
      .update({ status: 'completed' })
      .eq('course_id', course.id)
      .eq('user_id', user?.id || '');
  }

  return (
    <main className="container mx-auto p-4">
      <div className="bg-white rounded-lg shadow-lg p-6">
        <Image
          src={course.image_url || 'https://via.placeholder.com/600x400'}
          alt={course.title}
          width={600}
          height={400}
          className="w-full h-64 object-cover rounded-lg mb-6"
          priority
        />
        <h1 className="text-3xl font-bold mb-4">{course.title}</h1>
        <p className="text-gray-600 mb-4">{course.description}</p>
        <p className="text-2xl font-semibold text-primary mb-6">{course.price.toLocaleString()}</p>
        {isPurchased ? (
          <p className="text-green-500 mb-6">このコースは購入済みです!</p>
        ) : (
          <a
            href={`/checkout?courseId=${course.id}`}
            className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 mb-6 inline-block"
          >
            このコースを購入
          </a>
        )}
        {searchParams.success && <p className="text-green-500 mb-6">購入が完了しました!</p>}
        {searchParams.canceled && <p className="text-red-500 mb-6">購入がキャンセルされました。</p>}
        <h2 className="text-2xl font-semibold mb-4">レッスン一覧</h2>
        {isPurchased ? (
          <div className="space-y-6">
            {lessonsWithSignedUrls.map((lesson) => (
              <div key={lesson.id} className="border rounded-lg p-4">
                <h3 className="text-xl font-semibold">{lesson.title}</h3>
                <p className="text-gray-600 mb-4">{lesson.description}</p>
                <ReactPlayer
                  url={lesson.signedUrl}
                  controls
                  width="100%"
                  height="auto"
                  className="rounded-lg"
                />
              </div>
            ))}
          </div>
        ) : (
          <p className="text-gray-600">コースを購入するとレッスンを視聴できます。</p>
        )}
      </div>
    </main>
  );
}

このコードは:

  • 購入ステータスを確認し、購入済みの場合のみビデオを表示。
  • 決済成功時にpurchasesテーブルのステータスをcompletedに更新。
  • 成功/キャンセルメッセージを表示。

ステップ6: 動作確認

  1. Supabaseダッシュボードで以下を確認:
    • purchasesテーブルとRLSポリシーが正しい。
    • Stripeダッシュボードでテストモードが有効。
  2. 開発サーバーを起動:
npm run dev
  1. http://localhost:3000/courses/[course-id]にアクセスし、以下の点を確認:
    • 未購入の場合、「このコースを購入」ボタンが表示され、/checkout?courseId=[id]に遷移。
    • チェックアウトページでコースIDを入力し、Stripe Checkoutにリダイレクト。
    • テストカード(例: 4242 4242 4242 4242)で決済を完了し、成功メッセージが表示。
    • 購入後、レッスンビデオが表示され、再生可能。
    • キャンセルした場合、キャンセルメッセージが表示。
  2. Supabaseのpurchasesテーブルで、購入データ(user_id, course_id, status)が正しいことを確認。

エラーがあれば、StripeのAPIキー、SupabaseのRLS、またはAPIルートのログを確認してください。


まとめと次のステップ

このエピソードでは、Stripeを使ってコース購入機能を実装しました。チェックアウトページを構築し、APIルートでStripeセッションを処理、購入情報をSupabaseに保存しました。これで、ユーザーがコースを購入してコンテンツにアクセスできるようになりました!

次回のエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装します。commentsテーブルを設計し、ユーザーがレッスンにコメントを投稿・閲覧できるUIを構築しますので、引き続きお楽しみに!


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

2
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
2
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?