【Next.js】チケット販売システムを作る③ : 座席選択機能と確率ベースのSoldOut設計

Programming

前回はチケット販売システムを作る② で簡易Botチェックを実装しました。

今回は、インターパークの座席選択を想定した練習機能について解説します。

本来は、Botチェック後に座席の区域(等級)を選択し、その中から空いている席をクリックして予約する流れになりますが、今回は区域選択の機能は実装していません。

Botチェック完了後、すぐに座席選択が始まります。そして、4秒以内にすべての座席がSoldOutになる環境を用意しました。

そのため、「欲しい座席をいかに素早くクリックできるか」を練習できる仕組みになっています。

この記事でわかること
  • コンポーネント分割の考え方
  • 進行度ベースの確率設計
  • Reactでの状態監視パターン

座席レイアウトの構成

最初はいきなりGridを使って座席のレイアウトしましたが、一つの座席を管理するために以下の通り役割を分けてコンポーネントを作成しました。

  • Seat(座席UI)
  • SeatGrid(レイアウト担当)
  • Page(状態管理・クリック処理・ゲーム制御)

Seatコンポーネント

Seatsは1つの座席のUIのみを担当するコンポーネントです。

状態は持たず、親コンポーネントから渡された

  • seat情報
  • clickイベント

のみを使用します。

SeatType(型定義)

座席の情報は、TypeScriptで型としてまとめています。状態を文字列リテラル型で定義することで、タイプミスや想定外の値を防ぐことができます。

コードを見る
//types/seat.ts
export type SeatStatus = "available" | "taken" | "selected"; //座席状況


export type SeatType = {
    seatNum : string; //座席番号
    grade : "S" | "R" | "V"; //座席等級
    status : SeatStatus; //現在の状態
}
Seat情報

クリックされた座席番号のみを親へ渡し、実際の状態変更はPageコンポーネントで管理しています。

コードを見る
/** SeatUI Component */

"use client"

import type { SeatType } from "@/types/seat";

type SeatProps = {
    seat : SeatType;
    onClick : (seatNum : string) => void; 
}

export default function Seat({seat, onClick} : SeatProps) {

    // seatStatus color mapping
    const getColor = () => {
        if (seat.status === "taken") return "bg-red-500";
        if (seat.status === "selected") return "bg-blue-500";
        return "bg-purple-500";
    }

    return(
        <div className={`${getColor()} w-3 h-3 text-xs flex items-center justify-center text-white cursor-pointer hover:opacity-80`}
            onClick={() => onClick(seat.seatNum)}
        >
        </div>
    )
}
ヒョニ
ヒョニ

座席の状態に応じて、背景色を変更しています。

 const getColor = () => {
        if (seat.status === "taken") return "bg-red-500";
        if (seat.status === "selected") return "bg-blue-500";
        return "bg-purple-500";
    }

SeatGridコンポーネント

SeatGridは、SeatコンポーネントをGridレイアウトで配置する役割を持っています。列数はpropsで受け取り、CSS Gridを利用して動的にレイアウトを構成しています。

コードを見る
/** SeatGrid Component */

"use client"

import { SeatType } from "@/types/seat"
import Seat from "./Seat";

type SeatGridProps = {
    seats : SeatType[];
    cols : number;
    onSeatClick: (seatNum : string) => void;
}

export default function SeatGrid({seats, cols, onSeatClick} : SeatGridProps) {

    return(
        <div className={`grid gap-1`}style={{ gridTemplateColumns: `repeat(${cols}, 1fr)` }}>
           {
            seats.map((seat) => (
                <Seat 
                    key={seat.seatNum}
                    seat={seat}
                    onClick={onSeatClick}
                />
            ))
           }
        </div>
    )
}

Pageコンポーネント

Pageでは、

  • 座席データの生成と同時に座席減少スタート
  • SoldOutのシミュレーション→4秒以内にSoldOut
  • ゲームの成功 / 失敗判定
  • クリック処理

をまとめて管理しています。

今回のアプリでは、座席の状態をすべてPageで一括管理する設計にしました。

State変数宣言

状態管理のため、State変数を宣言します。

  • captchaPassed → Botチェック成功有無
  • seats → 座席情報
  • timeLeft → 残り時間セット(4秒)
  • gameStatus → 座席予約のゲーム状態
コードを見る
  const [captchaPassed, setCaptchaPassed] = useState(false)
  const [seats, setSeats] = useState<SeatType[]>(generateSeats());
  // 좌석 예약 게임
  const [timeLeft, setTimeLeft] = useState(4);
  const [gameStatus, setGameStatus] = useState<GameStatus>("ready");
  
  //types/seat.ts
  export type GameStatus = "ready" | "running" | "success" | "fail";
座席生成

座席ページにアクセスすると、座席データを生成します。生成と同時に、一部の座席のステータスを「taken(売り切れ)」に設定しています。これにより、実際のチケット争奪戦に近い状況を再現しています。

  • RowsとColsを使って座席サイズを決定
  • for文を使った座席データの生成
  • 初期状態で30%の座席をSoldOutに設定
コードを見る
const ROWS = 10; //行
const COLS = 30; //列

  const generateSeats = ():SeatType[]=>{
    const seats:SeatType[] = [];

    for(let r=0; r<ROWS; r++){
      for(let c=0; c<COLS; c++){
        seats.push({
          seatNum: `${r}-${c}`, //ex) 0-1, 0-2, 0-3,,,
          grade: "S",
          status: Math.random() < 0.30 ? "taken" : "available", //座席の座席の30%を "taken"状態にする
        });
      }
    }

    return seats;
  }
ヒョニ
ヒョニ

座席生成関数 generateSeats はコンポーネントの外に定義しています。
useState(generateSeats()) はコンポーネントが実行されたタイミングで即座に評価されます。しかし、const で定義した関数は宣言前に使用することができません。そのため、関数をコンポーネントより下に書いてしまうとエラーが発生します。

座席減少 & タイムアウト判定ロジック

4秒以内にSoldOut状態になるようにするため、時間の経過に応じて座席のステータスを徐々に「taken」に変更する仕組みを実装しました。

単純にランダムで減らすのではなく、進行度に応じて確率が上がる設計にしています。

  • 残り時間(timeLeft)を利用して進行度(progress)を計算
    • 時間が減るほど進行度が上がる
  • progressを利用して確率(probability)を計算
    • 序盤は低い確率、終盤になるほど高い確率になる
  • probabilityを利用してSeatsの状態を変更
    • 1秒ごとにすべての座席をチェックし、各座席が独立して確率判定を受ける。条件を満たした座席のみが「available」から「taken」に変更される。
  • 残り時間が「0」になるとタイムアウト
    • timeLeftが「0」なら、ゲームのステータスを「fail」に変更しゲームを終了。
コードを見る

 useEffect(() => {
 
  if (gameStatus !== "running") return;

  const gameTimer = setInterval(() => {

    setTimeLeft(prev => {
      const newTime = prev - 1; // 例) 4 → 3 → 2 → 1 → 0
      const progress = (10 - prev) / 10; // 例) 0.6 → 0.7 → 0.8 → 0.9
      const probability = 0.05 + progress * 0.5; // 例) 0.35 → 0.40 → 0.45 → 0.50

      setSeats(prevSeats => {
        return prevSeats.map(seat => {

          // 例)Math.random()の出力値=0.12 , probabilityの出力値=0.35の場合座席は「taken」
          if (seat.status === "available" && Math.random() < probability) {
            return { ...seat, status: "taken" };
          }
          return seat;
        });
      });

      //Timeoutの場合はゲーム終了
      if (newTime <= 0) {
        setGameStatus("fail");
        return 0;
      }

      return newTime;
    });

  }, 1000);

  return () => clearInterval(gameTimer); //clearIntervalは忘れずに!

}, [gameStatus]);
ゲーム成功条件

ユーザーが座席を選択し、ステータスが「selected」に変更された時点でゲーム成功と判定します。

seats の状態を監視し、「selected」 の座席が1つでも存在すれば ゲームのステータスを「success」 に変更します。

コードを見る

    useEffect(() => {
    if (gameStatus !== "running") return;

    if (seats.some((seat) => seat.status === "selected")) {
      setGameStatus("success");
      alert("Success! 🎉")
    }
  }, [seats, gameStatus]);
クリック処理

ユーザーが「available」状態の座席をクリックすると、その座席のステータスを「selected」に変更します。一方で、すでに「taken」状態の座席をクリックした場合は、予約できない旨のアラートを表示します。

コードを見る
const handleSeatClick = (seatNum: string) => {
    setSeats((prevSeats) =>
      prevSeats.map((seat) => {
        if (seat.seatNum === seatNum && seat.status === "available") {
          return {
            ...seat,
            status: "selected",
          };
        }
        if (seat.seatNum === seatNum && seat.status === "taken") {
          alert("This seat is no longer available.");
        }
        return seat;
      })
    );
  }

終わりに

本記事では、インターパークを想定したチケット予約練習アプリのロジック全体について解説しました。コンポーネント分割、状態管理、そして進行度に応じた確率設計など、実際のチケット争奪戦を意識した構成になっています。

まだ改善の余地はありますが、クリック速度の練習やロジック理解には十分活用できるアプリになったと感じています。次回は、このプロジェクトを Vercelでデプロイする方法 について紹介する予定です。

ここまでお読みいただき、ありがとうございました。

タイトルとURLをコピーしました