📅

microCMSでカレンダー型予約フォームを作りました【Astro.js】

2024/12/09に公開

こちらは「microCMSでこんなことができた!あなたのユースケースを大募集 by microCMS Advent Calendar 2024」8日目の記事です。

はじめに

こんにちは。最近ようやくAIの波に乗ってきたしょうみゆです。大遅刻です。

以前microCMSでホームページを制作したお教室を運営されているお客様から、「お問い合わせのときにこちらの空いてる日時で予約してもらえたらお客さん(エンドユーザー)とのやりとりが楽だよね・・」と相談をいただいていました。

Googleカレンダーでも「予約スケジュール機能」をリリースするなど、予約フォームのサービスはたくさんありますが、有料であることやオフラインのレッスンなどで数時間単位の予約が良い、メールでやりとりする、カレンダーをホームページのデザインに合わせたいなどの要件を考えてmicroCMSで自作できないかな〜ってずっと考えていて、勢いでアドベントカレンダーにエントリーしたのを機にようやく作りました。笑

デモサイト

microCMSで予約フォームを作ろう


microCMSの画面

受付時間は1日に何個でも、microCMSから自分の好きな時間を指定できます。
(日付がダブると最初の日付のほうが優先されます)

ちなみに、デモサイトではカレンダーページと予約フォームしかありませんが、実際の運用ではレッスンごとに予約カレンダーを設置する想定です。
カスタムフィールドで追加するので、APIの枠を消費せずに運用できます!(結構大事!笑)

この記事のターゲット

  • ある程度microCMSを触ったことがある人
  • Astro.jsが少しわかる人

環境

  • Node.js 20.5.1
  • Astro.js 4.12.2

Astro.jsはSSGで利用します。

microCMSのAPI設定

サービス作成までの初期設定は省略します。

API作成(初期)

オブジェクト形式で作成します。
後からカスタムフィールドを入れるので一旦適当にテキストフィードだけ入れておきます。

endpoint: reservation

カスタムフィールド作成

2つ作ります。

カスタムフィールド名:受付時間
カスタムフィールドID:entryTime

フィールドID 表示名 種類 必須 詳細設定
entryTime 時間 テキストフィールド true

カスタムフィールド名:受付日
カスタムフィールドID:entryDate

フィールドID 表示名 種類 必須 詳細設定
entryDate 受付日 日時 true 日付指定のみON
entryTimes 受付時間 繰り返し true 受付時間(entryTime)を選択

APIスキーマ設定

API設定 > APIスキーマ から改めて設定していきます。
最初に適当に作成したフィールドを削除して、以下のように追加します。

endpoint: reservation

フィールドID 表示名 種類 必須 詳細設定
entryMonth 受付月 日時 true 日付指定のみON
entryDates 受付日一覧 繰り返し true 受付日(entryDate)を選択

入稿

適当にデータを入れてみてください。
受付月は該当の月であれば1日〜31日までどれを選んでもOKです。

Astro.jsのセットアップ

Astro.jsにmicroCMSと連携したボイラープレートを作ってるのでこちらで解説します。
astro-boilerplate - GitHub

プロジェクトをダウンロードして解凍したら、$ npm installしておきます。

microCMSに接続

プロジェクトルートにある.env.templateファイルをコピーして.envファイルを作成します。

  • MICROCMS_SERVICE_DOMAINにサービスIDを入れます。(.microcms.ioいらない)
  • MICROCMS_API_KEYにAPIキーを入れます。

型定義

最近TypeScript頑張ってます。

/src/types/配下にmicrocmsReservation.tsファイルを作成し、以下追加します。

import { createClient, type MicroCMSQueries } from "microcms-js-sdk";

const client = createClient({
  serviceDomain: import.meta.env.MICROCMS_SERVICE_DOMAIN,
  apiKey: import.meta.env.MICROCMS_API_KEY,
});

export interface Reservation {
  createdAt: string;
  updatedAt: string;
  publishedAt: string;
  revisedAt: string;
  entryMonth: string;
  entryDates: EntryDate[];
}

export interface EntryDate {
  fieldId: string;
  entryDate: string;
  entryTimes: EntryTime[];
}

export interface EntryTime {
  fieldId: string;
  entryTime: string;
}

//APIの呼び出し
export const getReservation = async (queries?: MicroCMSQueries) => {
  return await client.get<Reservation>({
    endpoint: "reservation",
    queries,
  });
};

フロント実装

$ npm run devで立ち上がりますが、index.astroを書き換えないとエラー画面になります。

カレンダーページ

/src/pages/index.astroを以下に書き換えます。

細かいことは省きますが、カレンダーを生成してAPIと一致する日付にリンクを追加しています。
カレンダーのリンクはURLにパラメータを付けてページ遷移するように設定しています。

---
import Layout from "@/layouts/Layout.astro";
import { formatInTimeZone } from "date-fns-tz";

import { getReservation, type EntryTime } from "@/types/microcmsReservation";
const reservationRes = await getReservation();

interface CalendarDay {
  day: number | null; // 日付 (nullなら空白)
  times?: EntryTime[]; // 該当する時間のデータ
}

// 現在日付を設定
const jstDate = formatInTimeZone(
  new Date(reservationRes.entryMonth),
  "Asia/Tokyo",
  "yyyy-MM-dd",
);
const currentYear: number = parseInt(jstDate.split("-")[0]);
const currentMonth: number = parseInt(jstDate.split("-")[1]) - 1;

// 月名の配列
const weeks = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const monthNames: string[] = Array.from({ length: 12 }, (_, i) =>
  String(i + 1),
);

// JSONデータから該当する日付と時間をマッピングする関数
function getTimesForDay(day: number): EntryTime[] | undefined {
  const date = new Date(currentYear, currentMonth, day);
  const targetDate = formatInTimeZone(date, "Asia/Tokyo", "yyyy-MM-dd");

  // entryDateをJSTに変換して比較
  const match = reservationRes.entryDates.find((d) => {
    if (!d.entryDate) return false;
    const entryDateJST = formatInTimeZone(
      new Date(d.entryDate),
      "Asia/Tokyo",
      "yyyy-MM-dd",
    );
    return entryDateJST === targetDate;
  });

  return match?.entryTimes;
}

// 当月のカレンダーを生成する関数
function generateCalendar(): CalendarDay[] {
  const firstDay = new Date(currentYear, currentMonth, 1).getDay(); // 月の初日の曜日
  const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate(); // 月の日数
  const calendar: CalendarDay[] = [];

  // 空白部分 (先頭を埋める)
  for (let i = 0; i < firstDay; i++) {
    calendar.push({ day: null });
  }

  // 日付部分
  for (let day = 1; day <= daysInMonth; day++) {
    const times = getTimesForDay(day);
    calendar.push({ day, times });
  }

  return calendar;
}

const calendar = generateCalendar();
---

<Layout isTopPage={true}>
  <main>
    <h1 class="pageTitle">{currentYear}年{monthNames[currentMonth]}月</h1>
    <table class="calendar">
      <thead>
        <tr class="calendar-header">
          {weeks.map((week) => <th>{week}</th>)}
        </tr>
      </thead>
      <tbody>
        {
          Array.from({ length: Math.ceil(calendar.length / 7) }, (_, i) => (
            <tr>
              {calendar.slice(i * 7, (i + 1) * 7).map((day) => (
                <td class={`day ${day.day ? "" : "empty"}`}>
                  {day.day && <div class="date-number">{day.day}</div>}
                  {day.times &&
                    day.times.map((time) => {
                      const dateParam = `${monthNames[currentMonth]}月${day.day}日 ${time.entryTime}`;

                      return (
                        <form action="/contact" method="get">
                          <input type="hidden" name="date" value={dateParam} />
                          <button type="submit" class="time-link">
                            {time.entryTime}
                          </button>
                        </form>
                      );
                    })}
                </td>
              ))}
            </tr>
          ))
        }
      </tbody>
    </table>
  </main>
</Layout>

<style lang="scss">
  main {
    padding-block: 64px;
    padding-inline: 16px;
  }
  .pageTitle {
    text-align: center;
    margin-bottom: 24px;
  }
  .calendar {
    width: 100%;
    max-width: 1200px;
    margin: auto;
    border-collapse: collapse;
    border-spacing: 1px;
  }
  .calendar-header {
    font-size: 14px;
    background-color: #fafafa;

    th {
      padding: 10px;
      border: 1px solid #ddd;
    }
  }
  .date-number {
    margin-bottom: 8px;
  }
  .day {
    font-weight: bold;
    text-align: center;
    width: calc(100% / 7);
    padding: 10px;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 14px;
    height: 120px;
    vertical-align: top;
  }
  th:nth-child(1),
  td:nth-child(7n + 1) {
    color: #ff0000;
  }
  th:nth-child(7),
  td:nth-child(7n) {
    color: #0000ff;
  }
  .time-link {
    color: #000;
    font-weight: normal;
    text-decoration: none;
    background: none;
    border: 1px solid #ddd;
    border-radius: 2px;
    color: inherit;
    cursor: pointer;
    padding: 4px;
    display: block;
    width: 100%;
    text-align: center;
    margin-top: 4px;
    &:hover {
      border-color: #ff0000;
      background-color: #fff0f5;
    }
  }
</style>

フォームページ

/src/pages/contact.astroを作成し、以下を追加します。

URLのパラメータから日付を取得してフォームに反映させています。
styleなどが入っているのでコード長いですが、必要なところはscriptタグ内のみで、あとは何の変哲もない普通のフォームでOKです。

---
import Layout from "@/layouts/Layout.astro";
---

<Layout title="予約フォーム">
  <main>
    <h1 class="pageTitle">予約フォーム</h1>

    <noscript>
      <div class="noscript-warning">
        ※JavaScriptを有効にしてください。予約日時の自動入力機能が利用できません。
      </div>
    </noscript>

    <form action="#!" method="POST" class="contactForm">
      <dl class="contactForm__parts">
        <dt class="contactForm__label required">
          <label for="yourName">お名前</label>
        </dt>
        <dd class="contactForm__entry">
          <input
            id="yourName"
            type="text"
            name="お名前"
            placeholder="山田太郎"
            required
          />
        </dd>
      </dl>
      <dl class="contactForm__parts">
        <dt class="contactForm__label required">
          <label for="yourMail">メールアドレス</label>
        </dt>
        <dd class="contactForm__entry">
          <input
            id="yourMail"
            type="email"
            name="メールアドレス"
            placeholder="info@test.com"
            required
          />
        </dd>
      </dl>
      <dl class="contactForm__parts">
        <dt class="contactForm__label required">
          <label for="yourTel">電話番号</label>
        </dt>
        <dd class="contactForm__entry">
          <input
            id="yourTel"
            type="tel"
            name="電話番号"
            placeholder="09012341234"
            required
          />
        </dd>
      </dl>
      <dl class="contactForm__parts">
        <dt class="contactForm__label">
          <label for="date">予約希望日</label>
        </dt>
        <dd class="contactForm__entry">
          <input
            id="date"
            type="text"
            name="ご希望日"
            placeholder="◯月◯日 10:00-14:00"
          />
        </dd>
      </dl>
      <button type="submit" class="button">送信する</button>
    </form>
  </main>
</Layout>

<script>
  // ページ読み込み時にURLパラメータを取得して入力欄に設定
  document.addEventListener("DOMContentLoaded", () => {
    const params = new URLSearchParams(window.location.search);
    const date = params.get("date");
    if (date) {
      const decodedDate = decodeURIComponent(date);
      const dateInput = document.getElementById("date") as HTMLInputElement;
      if (dateInput) {
        dateInput.value = decodedDate;
      }
    }
  });
</script>

<style lang="scss">
  main {
    padding-block: 64px;
    padding-inline: 16px;
  }
  .pageTitle {
    text-align: center;
    margin-bottom: 24px;
  }
  .noscript-warning {
    background-color: #fff3cd;
    border: 1px solid #ffeeba;
    color: #856404;
    padding: 12px;
    margin-bottom: 24px;
    border-radius: 4px;
    font-size: 14px;
    text-align: center;
  }
  .contactForm {
    margin-top: 40px;
    max-width: 600px;
    margin: auto;
    &__parts {
      margin-bottom: 24px;
    }

    &__label {
      font-size: 16px;
      margin-bottom: 8px;
      small {
        font-size: 14px;
      }
      &.required {
        &::after {
          content: "*";
          color: red;
          margin-left: 4px;
        }
      }
    }
    &__entry {
      input {
        font-size: 100%;
        line-height: 40px;
        width: 100%;
        background-color: #fafafa;
        padding: 4px 16px;
        border-radius: 4px;
        border: 1px solid #ddd;

        &::placeholder {
          color: #bbb;
        }
      }
    }
  }
  .button {
    display: block;
    line-height: 48px;
    width: 100%;
    max-width: 320px;
    margin: 64px auto 0;
    background-color: #007bff;
    color: #fff;
    padding: 10px;
    border-radius: 4px;
    &:hover {
      opacity: 0.8;
    }
  }
</style>

実際にカレンダー側からリンクをクリックして、フォームに日時が入っているか確認してみてください。

リポジトリも公開中です。
microcms-reservation-test - GitHub

終わりに

前にmicroCMSで時間割も作ったことがあるのでカレンダー予約もできるだろうなと思っていました。
実際に作ってみてちょっと大変だったところもありましたが、microCMSで日付指定して時間を手入力するだけなので、使うほうも結構手軽でお客さんも喜んでくれるのではと思ってます(無料だし笑)✊

あとは管理画面の繰り返しフィールドについて、+ボタンが近くてどのフィールドを増やしているのか時々迷子になりがちなので、もう少し見やすくなったらもっと使いやすくなるだろうなって思ってます。
microCMSさん、よろしくお願いします!!笑

AstroもWeb制作くらいの規模では使いやすくて、最近はもっぱらAstroで開発しています。
WordPressが最近色々と問題になっているのでヘッドレスCMSでWeb制作する人が増えたらいいなと思っているのですが、Web制作のライト層にとってヘッドレスCMSを使うにはまだまだ課題が多いので、もっと広められるように課題解決への取り組みしかり簡単に制作できる開発環境とか色々作りたいです!来年の抱負!

・・・ちなみに今回Astroで作りましたが、そういえばお客様のサイトはNuxtでした。笑

Discussion