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

カフェのメニュー管理が大変と聞きMicroCMSでワンソースマルチユースなプロトタイプを作ってみた

Last updated at Posted at 2025-12-24

最近地域活動をしていて、近所の飲食店を経営されている方と知り合いになったのですが、 「季節ごとに変わるメニュー管理が大変なんだよね〜」 と不満を漏らしていました。

テーブル用の紙メニューやチラシに加え、ウェブサイトやSNSでも更新しないといけないらしく。

飲食店のようにデジタルだけなく紙などのアナログまでアウトプットが必要なケースに、MicroCMSはピッタリじゃないか!?と思い、生成AIをフル活用して、ワンソース・マルチユースなプロトタイプを作成してみました。

飲食店が抱える課題

飲食店には定期的なメニューの更新というタスクがあります。

image.png

ずっと同じメニューというお店は少なく、春夏秋冬のシーズンごとや短いスパンだと毎月メニューを変えるお店もあります。

地味にメニューを変えていくのは大変な作業です。

今回相談を受けた飲食店のオーナーさんのお話だと、エクセルでメニュー情報自体を管理しているものの、お店のウェブサイトやメニュー表のデザインにコピペしているそうです。

そのため、一元管理となっていないという課題を抱えていました。

ウェブサイトやメニュー表を編集している間にメニュー名や説明文、料金をエクセルで修正した際に反映漏れがおきてしまうことがあります。

image.png

実際にメニュー表の場合、印刷会社に発注して届いてから気づく事故があったそうです。

再印刷にお金をかけるわけにはいかず、そのときは修正テープを貼って対応したとのことでした。

アウトプットが多岐に渡るリスク

メニュー更新にはアウトプットがウェブサイトからSNS、メニュー表、看板まで多様なことがリスクになっています。

飲食店の運営は接客から調理、売上管理までやることが膨大です。

image.png

そんな通常の業務が忙しい中で、メニュー改定まで取り組むと管理しきれなくなります。

アウトプットごとに編集作業しているため、メニュー管理しているエクセルを更新しても、更新が漏れてしまうことは人間の性質上、完全に回避はできません。

特にマルチタスクが苦手な人の場合は、メニュー表においては事故が起きやすく、確認や再印刷、修正などのコストが増大してしまいます。

ワンソースマルチユースが課題解決に

ではどうやってメニュー表の課題を解決すればよいのでしょうか?

飲食店のオーナーさんのお話を聞いて、私が考えたのは ワンソースマルチユース でした。

image.png

現在はエクセルをメニュー管理に利用しているものの、ウェブサイトやメニュー表の印刷用デザインの作成時にはコピペが発生しています。

このコピペという人手の作業があるから、更新漏れや先祖帰りといったミスが起きうる構造です。

であれば、元々1つのメニュー表のソースとなるデータを用意し、ウェブサイトや印刷用データはそこを参照する形にすれば、更新作業は大元のデータソースのみで済みます。

「1つのソースからウェブサイトやSNS、印刷用データまでマルチに活用することで、飲食店のメニュー管理のリスクを下げることができるのでは?」 と仮説を立てました。

ワンソースマルチユースにピッタリMicroCMS

ワンソースマルチユースにピッタリなサービスがMicroCMSです。

image.png

MicroCMSはヘッドレスCMSに分類され、Wordpressなどと違い、フロントエンドの表示機能がありません。

その代わり、CMSに格納あれているデータをバックエンドで取得してフロントエンドは好きな環境で構築することができます。

  1. MicroCMS × Next.js
  2. MicroCMS × Laravel
  3. MicroCMS × Hono

自分たちが得意としているフレームワークやプログラミング言語で利用可能です。

今回はウェブサイトやLINE公式アカウント、SNSに加えて印刷用のデータを扱うこと、プロトタイプということで、手軽GoolgeスライドなどでPDFが作れるGoogle Apps Script(GAS)でプロトタイプを制作していきます。

【閑話休題】GASならスプレッドシートでよくない?

Google Apps Script(GAS)を使っている人からすると、データソースはスプレッドシートでいいんじゃないかと思ったかもしれません。

GASではスプレッドシートをデータソースとして蓄積・利用するケースも多いです。

image.png

しかし、スプレッドシートは手軽に値を入力できる反面、入力ミス等も起こります。

そうした入力ミスを減らすには、簡単に書き換えできないようなUI/UXが効果的です。

さらにスプレッドシートは処理速度が遅いため、書き込み処理が終わるまでに時間がかかります。

複数の処理がほぼ同じタイミングで走ってしまうと、データの整合性が保てなくなる恐れがあります。

そうした場合にMicroCMSはAPI経由で多重制御が担保されており、スピーディーに処理可能です。

スプレッドシートのような自由なフォーマットはない反面、作業ミス等が発生しづらいです。

そうしたデータの堅牢性の観点で、データの保管先はスプレッドシートではなくMicroCMSとしました。

GASは性能要件が厳しくないプロトタイプで用いられることが多いですが、MicroCMSを使うことで、多重制御や同時書き込みなどをMicroCMS側におまかせできて便利です。

メニューのデータをMicroCMSに定義

まず、MicroCMSにメニューのデータを定義する必要があります。

MicroCMSでは以下のように①サービス作成、②コンテンツ作成、③APIから利用の3Stepで実施していきます。

image.png

MicroCMSの管理画面にログインした状態で、まずサービスを作成していきます。

image.png

基本的にブログサイトを作る場合は便利なテンプレートが用意されていますが、カフェのメニューはブログ記事とは保持するデータが異なります。

そのため、テンプレートは使用せずに「一から作成する」を選択します。

サービス名とサービスIDを入力して今回のメニュー管理用のサービスを新規作成します。

続いてコンテンツとそれを呼び出すAPIを作成していきます。

image.png

ここでもテンプレートではなく「自分で決める」を選択し、カスタムなデータで以下の要素を持つようにしました。

  1. メニュー名
  2. メニュー価格
  3. メニューカテゴリ
  4. メニューの短い説明
  5. メニューの長い説明
  6. メニューの写真

image.png

あとはMicroCMSの管理画面からデータを追加していくことができます。

またCSVファイルからインポートで一括追加も可能です。

ただし、CSVはデータフォーマット通りに列が配置されている必要があり、画像URLもMicroCMSにアップロードした画像のURLが必要です。

image.png

CSVから一括で登録できるのですが、事前に画像をMicroCMSにアップロードし、各画像のMicroCMS上のURLを取得して、対応するメニューに貼り付けが必要でした。

今回はプロトタイプなのでメニューはいらすとやの画像を使用しています。

coffee07_cafe_macchiato.png

またメニューの説明文などは生成AIに準備してもらいました。

image.png

こうしたテスト用データの準備は今まで大変で、つい「◯◯◯◯◯◯◯◯」と記載してしまっていましたが、生成AIを活用して本番に近いデータを低コストで準備できるようになったので便利になりました。

MicroCMSのAPIからデータを取得

事前準備ができたところで、MicroCMS上のデータによるワンソースマルチユースを展開していきます。

MicroCMSのAPIを叩いてデータを取得し、Google Apps Scriptからウェブサイトを表示したり、印刷用の紙データを生成したりしていきます。

アウトプット①ウェブサイト

まずはウェブサイトです。

Google Apps ScriptのWebアプリをデプロイしてメニューを表示するWebページを試作しました。

動かすためのGASのスクリプトとindex.htmlは以下の通りです。

main.gs

/**
 * MicroCMS APIからメニューデータを取得する関数
 * @return {Array} メニューデータの配列
 */
function fetchAllMenus() {
  const API_ENDPOINT = 'https://cafemenue.microcms.io/api/v1/menu';
  const LIMIT = 100; // 一度に取得する最大件数
  const API_KEY = PropertiesService.getScriptProperties().getProperty('MICROCMS_API_KEY');
  let allMenus = [];
  let offset = 0;
  let hasMore = true;

  // APIキーが設定されていない場合のエラーチェック
  if (!API_KEY) {
    Logger.log('エラー: MICROCMS_API_KEYがスクリプトプロパティに設定されていません');
    throw new Error('APIキーが設定されていません。スクリプトプロパティにMICROCMS_API_KEYを設定してください。');
  }

  while (hasMore) {
    const url = API_ENDPOINT + '?limit=' + LIMIT + '&offset=' + offset;
    
    try {
      const response = UrlFetchApp.fetch(url, {
        method: 'get',
        headers: {
          'X-MICROCMS-API-KEY': API_KEY
        }
      });
      
      const data = JSON.parse(response.getContentText());
      
      if (data.contents && data.contents.length > 0) {
        allMenus = allMenus.concat(data.contents);
        offset += data.contents.length;
        
        // すべてのデータを取得したかチェック
        if (data.contents.length < LIMIT || allMenus.length >= data.totalCount) {
          hasMore = false;
        }
      } else {
        hasMore = false;
      }
    } catch (error) {
      Logger.log('エラーが発生しました: ' + error.toString());
      hasMore = false;
    }
  }

  return allMenus;
}

/**
 * Webアプリとして公開するためのdoGet関数
 * @return {HtmlOutput} HTMLテンプレート
 */
function doGet() {
  const menus = fetchAllMenus();
  const template = HtmlService.createTemplateFromFile('index');
  template.menus = menus;
  // JSONを安全にエスケープして渡す
  template.menusJson = JSON.stringify(menus).replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
  return template.evaluate()
    .setTitle('カフェメニュー')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}


index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>カフェQiita - メニュー</title>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }

    body {
      font-family: 'Hiragino Sans', 'Hiragino Kaku Gothic ProN', 'Yu Gothic', 'Meiryo', sans-serif;
      background: linear-gradient(135deg, #f5f5f5 0%, #e8f5e9 100%);
      min-height: 100vh;
      padding: 0;
    }

    .header {
      background: linear-gradient(135deg, #55C500 0%, #4aad00 100%);
      color: white;
      padding: 30px 20px;
      text-align: center;
      box-shadow: 0 4px 12px rgba(85, 197, 0, 0.3);
      margin-bottom: 40px;
    }

    .cafe-name {
      font-size: 2.5em;
      font-weight: bold;
      letter-spacing: 2px;
      margin-bottom: 10px;
      text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
    }

    .cafe-subtitle {
      font-size: 1.1em;
      opacity: 0.95;
      font-weight: 300;
    }

    .container {
      max-width: 1200px;
      margin: 0 auto;
      padding: 0 20px 40px;
    }

    .section-title {
      text-align: center;
      color: #333;
      margin-bottom: 40px;
      font-size: 2em;
      font-weight: bold;
      position: relative;
      padding-bottom: 15px;
    }

    .section-title::after {
      content: '';
      position: absolute;
      bottom: 0;
      left: 50%;
      transform: translateX(-50%);
      width: 80px;
      height: 4px;
      background: linear-gradient(90deg, #55C500, #4aad00);
      border-radius: 2px;
    }

    .menu-grid {
      display: grid;
      gap: 25px;
      grid-template-columns: 1fr;
    }

    .menu-item {
      background-color: #fff;
      border-radius: 12px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
      overflow: hidden;
      transition: all 0.3s ease;
      border: 2px solid transparent;
      cursor: pointer;
    }

    .menu-item:hover {
      transform: translateY(-8px);
      box-shadow: 0 8px 24px rgba(85, 197, 0, 0.25);
      border-color: #55C500;
    }

    .menu-item:active {
      transform: translateY(-4px);
    }

    .menu-image-wrapper {
      position: relative;
      overflow: hidden;
      background: #f0f0f0;
    }

    .menu-image {
      width: 100%;
      height: 250px;
      object-fit: cover;
      display: block;
      transition: transform 0.3s ease;
    }

    .menu-item:hover .menu-image {
      transform: scale(1.05);
    }

    .menu-content {
      padding: 25px;
    }

    .menu-name {
      font-size: 1.6em;
      font-weight: bold;
      color: #333;
      margin-bottom: 12px;
      line-height: 1.3;
    }

    .menu-price-wrapper {
      display: flex;
      align-items: baseline;
      margin-bottom: 15px;
      padding: 10px 0;
      border-bottom: 2px solid #f0f0f0;
    }

    .menu-price {
      font-size: 1.8em;
      color: #55C500;
      font-weight: bold;
      margin-right: 5px;
    }

    .menu-price::after {
      content: '円';
      font-size: 0.7em;
      margin-left: 3px;
      color: #55C500;
    }

    .menu-desc {
      font-size: 1em;
      color: #666;
      line-height: 1.8;
      letter-spacing: 0.5px;
    }

    /* PCビュー: 3列 */
    @media (min-width: 768px) {
      .header {
        padding: 40px 20px;
      }

      .cafe-name {
        font-size: 3.5em;
      }

      .cafe-subtitle {
        font-size: 1.3em;
      }

      .menu-grid {
        grid-template-columns: repeat(3, 1fr);
        gap: 30px;
      }

      .menu-image {
        height: 300px;
      }

      .section-title {
        font-size: 2.5em;
        margin-bottom: 50px;
      }
    }

    /* モーダルオーバーレイ */
    .modal-overlay {
      display: none;
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.6);
      z-index: 1000;
      animation: fadeIn 0.3s ease;
    }

    .modal-overlay.show {
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 20px;
    }

    @keyframes fadeIn {
      from {
        opacity: 0;
      }
      to {
        opacity: 1;
      }
    }

    @keyframes slideUp {
      from {
        transform: translateY(30px);
        opacity: 0;
      }
      to {
        transform: translateY(0);
        opacity: 1;
      }
    }

    /* 吹き出し風ポップアップ */
    .popup-bubble {
      background: white;
      border-radius: 20px;
      padding: 30px;
      max-width: 500px;
      width: 100%;
      max-height: 80vh;
      overflow-y: auto;
      box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
      position: relative;
      animation: slideUp 0.3s ease;
      border: 3px solid #55C500;
    }

    .popup-bubble::before {
      content: '';
      position: absolute;
      bottom: -15px;
      left: 50%;
      transform: translateX(-50%) rotate(45deg);
      width: 30px;
      height: 30px;
      background: white;
      border-right: 3px solid #55C500;
      border-bottom: 3px solid #55C500;
    }

    .popup-close {
      position: absolute;
      top: 15px;
      right: 15px;
      width: 35px;
      height: 35px;
      border-radius: 50%;
      background: #f0f0f0;
      border: none;
      cursor: pointer;
      font-size: 20px;
      color: #666;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all 0.3s ease;
      line-height: 1;
    }

    .popup-close:hover {
      background: #55C500;
      color: white;
      transform: rotate(90deg);
    }

    .popup-image {
      width: 100%;
      height: 200px;
      object-fit: cover;
      border-radius: 12px;
      margin-bottom: 20px;
    }

    .popup-name {
      font-size: 2em;
      font-weight: bold;
      color: #333;
      margin-bottom: 15px;
      padding-right: 40px;
    }

    .popup-price {
      font-size: 2.2em;
      color: #55C500;
      font-weight: bold;
      margin-bottom: 20px;
      padding-bottom: 15px;
      border-bottom: 2px solid #f0f0f0;
    }

    .popup-price::after {
      content: '円';
      font-size: 0.7em;
      margin-left: 5px;
      color: #55C500;
    }

    .popup-desc {
      font-size: 1.1em;
      color: #555;
      line-height: 2;
      letter-spacing: 0.5px;
    }

    @media (max-width: 767px) {
      .popup-bubble {
        max-width: 90%;
        padding: 25px;
        border-radius: 15px;
      }

      .popup-bubble::before {
        display: none;
      }

      .popup-name {
        font-size: 1.6em;
      }

      .popup-price {
        font-size: 1.8em;
      }

      .popup-desc {
        font-size: 1em;
      }
    }
  </style>
</head>
<body>
  <div class="header">
    <div class="cafe-name">カフェQiita</div>
    <div class="cafe-subtitle">〜心温まる一杯を、あなたに〜</div>
  </div>
  <div class="container">
    <h2 class="section-title">メニュー</h2>
    <div class="menu-grid">
      <? for (var i = 0; i < menus.length; i++) { ?>
        <div class="menu-item" onclick="showMenuDetail(<?= i ?>)">
          <div class="menu-image-wrapper">
            <img src="<?= menus[i].menu_image.url ?>" alt="<?= menus[i].menu_name ?>" class="menu-image">
          </div>
          <div class="menu-content">
            <div class="menu-name"><?= menus[i].menu_name ?></div>
            <div class="menu-price-wrapper">
              <div class="menu-price"><?= menus[i].menu_price ?></div>
            </div>
            <div class="menu-desc"><?= menus[i].menu_short_desc ?></div>
          </div>
        </div>
      <? } ?>
    </div>
  </div>

  <!-- モーダルオーバーレイ -->
  <div class="modal-overlay" id="modalOverlay" onclick="closeMenuDetail(event)">
    <div class="popup-bubble" onclick="event.stopPropagation()">
      <button class="popup-close" onclick="closeMenuDetail(event)">×</button>
      <img id="popupImage" src="" alt="" class="popup-image">
      <div class="popup-name" id="popupName"></div>
      <div class="popup-price" id="popupPrice"></div>
      <div class="popup-desc" id="popupDesc"></div>
    </div>
  </div>

  <script>
    // メニューデータをJavaScriptで使用できるようにする
    var menuData = <?= menusJson ?>;
    
    // デバッグ用(本番環境では削除可能)
    console.log('メニューデータ読み込み完了:', menuData ? (Array.isArray(menuData) ? menuData.length + '' : '配列ではない') : 'データなし');
    console.log('menuDataの型:', typeof menuData);
    if (menuData && Array.isArray(menuData) && menuData.length > 0) {
      console.log('最初のメニュー項目:', menuData[0]);
      console.log('最初のメニューのmenu_image:', menuData[0].menu_image);
    } else if (menuData && typeof menuData === 'string') {
      console.log('menuDataは文字列です。パースを試みます...');
      try {
        menuData = JSON.parse(menuData);
        console.log('パース成功:', menuData.length + '');
      } catch (e) {
        console.error('パース失敗:', e);
        menuData = [];
      }
    }

    function showMenuDetail(index) {
      try {
        console.log('showMenuDetail called with index:', index);
        console.log('menuData:', menuData);
        console.log('menuData length:', menuData ? menuData.length : 'undefined');
        
        if (!menuData) {
          console.error('menuDataが定義されていません');
          return;
        }
        
        var menu = menuData[index];
        console.log('Selected menu:', menu);
        
        if (!menu) {
          console.error('メニューが見つかりません: index=' + index);
          return;
        }

        // データの存在チェックを追加
        if (!menu.menu_image || !menu.menu_image.url) {
          console.error('menu_image.urlが存在しません:', menu);
          return;
        }

        document.getElementById('popupImage').src = menu.menu_image.url;
        document.getElementById('popupImage').alt = menu.menu_name || '';
        document.getElementById('popupName').textContent = menu.menu_name || '';
        document.getElementById('popupPrice').textContent = menu.menu_price || '';
        document.getElementById('popupDesc').textContent = menu.menu_long_desc || menu.menu_short_desc || '';

        var overlay = document.getElementById('modalOverlay');
        overlay.classList.add('show');
        document.body.style.overflow = 'hidden';
      } catch (error) {
        console.error('エラーが発生しました:', error);
        console.error('Error stack:', error.stack);
      }
    }

    function closeMenuDetail(event) {
      if (event) {
        event.stopPropagation();
      }
      var overlay = document.getElementById('modalOverlay');
      overlay.classList.remove('show');
      document.body.style.overflow = '';
    }

    // ESCキーで閉じる
    document.addEventListener('keydown', function(event) {
      if (event.key === 'Escape') {
        closeMenuDetail();
      }
    });
  </script>
</body>
</html>

GASプロジェクトに上記2つのファイルを配置し、スクリプトプロパティでMicroCMSのAPIキーを設定します。

その上で、Webアプリとしてデプロイすることでプロトタイプのウェブサイトを表示できるようになります。

image.png

下記のリンクから表示が確認できます。
https://script.google.com/macros/s/AKfycbxkfPsh0fKBGJjOLbgApDRmHrFEfidEDA49e47vHyn3LXP1Qa45gRMhZkmi4h7bPOWH/exec

メニューの一覧がわかりやすく表示されています。

image.png

各メニューをクリック(タップ)すると、詳細なメニュー説明も確認できるUIになっています。

アウトプット②LINE公式アカウント

次にLINE公式アカウントです。

せっかくなのでLINEでもメニューを出力できるようにしてみました。

linebot.gs
/**
 * LINE Bot用のWebhookエンドポイント
 * @param {Object} e - リクエストイベント
 * @return {TextOutput} レスポンス
 */
function doPost(e) {
  try {
    // リクエストボディをパース
    const events = JSON.parse(e.postData.contents).events;
    
    if (!events || events.length === 0) {
      return ContentService.createTextOutput('OK');
    }

    // 各イベントを処理
    events.forEach(function(event) {
      // メッセージイベントのみ処理
      if (event.type === 'message' && event.message.type === 'text') {
        handleMessage(event);
      }
    });

    return ContentService.createTextOutput('OK');
  } catch (error) {
    Logger.log('エラーが発生しました: ' + error.toString());
    return ContentService.createTextOutput('Error: ' + error.toString());
  }
}

/**
 * メッセージイベントを処理する関数
 * @param {Object} event - LINEイベントオブジェクト
 */
function handleMessage(event) {
  const replyToken = event.replyToken;
  const userMessage = event.message.text;

  // メニューデータを取得
  const menus = fetchAllMenus();

  if (!menus || menus.length === 0) {
    replyMessage(replyToken, 'メニューが見つかりませんでした。');
    return;
  }

  // メニュー情報をフォーマット
  let messageText = '【カフェQiita メニュー一覧】\n\n';
  
  menus.forEach(function(menu) {
    messageText += menu.menu_name + '' + menu.menu_price + '\n';
    messageText += menu.menu_short_desc + '\n\n';
  });

  // LINEに返信
  replyMessage(replyToken, messageText);
}

/**
 * LINE Messaging APIにメッセージを返信する関数
 * @param {string} replyToken - リプライトークン
 * @param {string} messageText - 送信するメッセージテキスト
 */
function replyMessage(replyToken, messageText) {
  const CHANNEL_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty('LINE_CHANNEL_ACCESS_TOKEN');
  
  if (!CHANNEL_ACCESS_TOKEN) {
    Logger.log('エラー: LINE_CHANNEL_ACCESS_TOKENがスクリプトプロパティに設定されていません');
    return;
  }

  const url = 'https://api.line.me/v2/bot/message/reply';
  
  const payload = {
    replyToken: replyToken,
    messages: [
      {
        type: 'text',
        text: messageText
      }
    ]
  };

  const options = {
    method: 'post',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN
    },
    payload: JSON.stringify(payload)
  };

  try {
    const response = UrlFetchApp.fetch(url, options);
    Logger.log('LINE返信成功: ' + response.getResponseCode());
  } catch (error) {
    Logger.log('LINE返信エラー: ' + error.toString());
  }
}

/**
 * MicroCMS APIからメニューデータを取得する関数
 * @return {Array} メニューデータの配列
 */
function fetchAllMenus() {
  const API_ENDPOINT = 'https://cafemenue.microcms.io/api/v1/menu';
  const LIMIT = 100; // 一度に取得する最大件数
  const API_KEY = PropertiesService.getScriptProperties().getProperty('MICROCMS_API_KEY');
  let allMenus = [];
  let offset = 0;
  let hasMore = true;

  // APIキーが設定されていない場合のエラーチェック
  if (!API_KEY) {
    Logger.log('エラー: MICROCMS_API_KEYがスクリプトプロパティに設定されていません');
    return [];
  }

  while (hasMore) {
    const url = API_ENDPOINT + '?limit=' + LIMIT + '&offset=' + offset;
    
    try {
      const response = UrlFetchApp.fetch(url, {
        method: 'get',
        headers: {
          'X-MICROCMS-API-KEY': API_KEY
        }
      });
      
      const data = JSON.parse(response.getContentText());
      
      if (data.contents && data.contents.length > 0) {
        allMenus = allMenus.concat(data.contents);
        offset += data.contents.length;
        
        // すべてのデータを取得したかチェック
        if (data.contents.length < LIMIT || allMenus.length >= data.totalCount) {
          hasMore = false;
        }
      } else {
        hasMore = false;
      }
    } catch (error) {
      Logger.log('エラーが発生しました: ' + error.toString());
      hasMore = false;
    }
  }

  return allMenus;
}


こちらもウェブサイトと同様にデプロイした上で、LINE公式アカウントのMessaging APIにWebhookURLとして、デプロイしたURLを設定します。

LINE公式アカウントのリッチメニューから前述のウェブサイトを呼び出してもよいのですが、チャット形式のUIもアリかなと思い試作しています。

image.png

生成AIを挟むことで単にメニューリストを出すだけでなく、ユーザーからの投稿に応じておすすめのメニューなどを紹介したりもできそうです。レコメンド的な紹介でエンゲージメントを高められるのではと仮説を立てています。

アウトプット③紙のメニュー表(テーブル用)

次はデジタルではなく、アナログな各テーブルに設置している紙のメニュー表です。

来店したお客さんが注文する際にはメニュー表で値段や内容確認はかかせません。

印刷できるデジタルのフォーマットとしてはPDFが選択肢の1つで、PDFに変換できる編集サービスとして、プロトタイプではGoogleスライドを選択しました。

GoogleスライドはGASから操作もできるので、MicroCMSのAPIからメニュー情報を取得して、反映も可能です。

エクスポートのフォーマットとしてPDFが選べるので、メニュー表のPDFを印刷すれば、紙メニューに対応できます。

事前にGoogleスライドに雛形となるメニューのデザインを用意しておきます。

MicroCMSのAPIからメニュー情報を取得してGoogleスライドの雛形をコピーしてメニューを出力するGASのスクリプトを用意し、実行すると、メニュー情報が記載されたメニュー表の電子データが完成です。

image.png

当該スライドをPDFで保存すれば、印刷すればメニュー表として利用できます。

OnOpen関数でGoogleスライドを開いた際にGASの関数が実行されるように設定しておけば、開いた時には常にMicroCMSのメニューが参照されて更新される形です。

アウトプット④チラシ掲載メニュー

メニュー表と同様に紙での印刷した媒体がチラシです。

ポスティングやビラ配りで使うチラシは飲食店によって認知拡大に繋げる重要なアイテムです。

チラシの中でもメニューを載せておくことでどういったドリンクやフードがあるか伝え、来訪をうながすことができます。

メニュー表と同じようにGoogleスライドのテンプレートを用意して、GASでMicroCMSから取得したメニューを埋め込みます。

image.png

メニュー表同様にonOpen関数を設定すれば、常にMicroCMSのメニューが参照されて更新される形です。

あとはチラシと同じ手順で「ファイル>ダウンロード>PDFドキュメント」でPDFを取得すれば、印刷データもMicroCMSのメニューと同期できます。

アウトプット⑤SNS発信

SNSについては試作できていませんが、X(旧Twitter)であれば、APIを利用した自動投稿が可能です。

MicroCMSのAPIでメニュー情報を取得して、日替わりで投稿するといったボットができます。

これによってSNS運用のコストを下げられます。

さらにChatGPTやGeminiなどの生成AIを組み合わせ、季節や時間帯、天気に応じた投稿も実現できます。

飲食店を探す中で、SNSチェックしてみると更新がストップしていると不安を感じる人も多いです。

MicroCMSで季節のメニューなど更新すれば、自動的に新しいメニューを取得し、AIが説明文を加工して投稿してくれます。

MicroCMSの更新すれば個別調整不要

ここまで紹介してきたアウトプット①〜⑥はすべてMicroCMSに登録されているデータを元にしています。

そのため、MicroCMSでデータを更新すれば、それぞれのアウトプットも更新されます。

image.png

これによって、バラバラでデータを保持している際に発生する、一部アウトプットでの更新漏れや先祖返りなどを防止できます。

今回はプロトタイプなので一例ですが、上記以外にも定期的にアップデートする情報がある場合、個別管理ではミスは防げません。

そんななか、MicroCMSによるワンソース・マルチユースで更新ミスは劇的に減らすことができ、更新作業の負担も軽減されます。

スマホからもMicroCMSの更新は簡単

今回、自分でMicroCMSにデータを投入する際はパソコンから実行しましたが、カフェの店主の方が主に使うのはスマホです。

そこでスマホのMicroCMSの登録UIがどうなっているかも調べました。

image.png

コンテンツ一覧画面等はスマホ最適化された表示ではありませんが、画面崩れも起きておらず、スマホでもパソコンと同様に操作可能なコンテンツ配置でした。

スマホから新しいメニューを追加画面は、スマホ最適化されており、簡単に追加できました。

image.png

初回のログインなどは難しいかもしれませんが、継続して使うユーザー的には問題ないレベルの簡単さでした。

プロトタイプに店主もにっこり

上記のウェブサイトや紙(印刷可能なPDF)、LINE botまで一通りMicroCMSでアウトプットが出力できるようになったところで、相談を受けたオーナーの人に見せてみました。

MicroCMSのみ更新するだけで各種プロトタイプのアウトレットが切り替わる様子に店主の方の反応も上々でした。

image.png

各アウトプットをテンプレート化しているので、個別に取り組まなくてもよいのでミスは激減できそうとの感触をいただきました。

また、MicroCMSの編集をスマホで試してもらいましたが、問題なく更新できそうでした。

さらに手が回っていないSNSでメニュー情報を自動的に投稿できる点も高評価でした。

あくまでプロトタイプのため、もう少し要件や細かな仕様を詰める必要はありますが、次のフェーズに進められそうです。

Canva連携でリッチなチラシやメニューも

今回はプロトタイプということで自分が扱った経験があり、操作しやすいGoogleスライドを利用し、紙メニューなどアナログ部分の印刷用PDFを生成しました。

しかし、メニューやチラシはもっとリッチなデザインも希望されるケースも多いです。

そうした方の場合はより凝ったデザインが可能なCanvaを使うこともできます。

image.png

CanvaにはAPIが提供されており、データのCRUD(作成・取得・追加・削除)が可能です。

あらかじめメニューを入力する枠を用意しておければ、そこにMicroCMSに登録されたメニューを挿入できます。

独自性を高めたメニュー表やチラシを扱いたい場合もCanva×MicroCMSを試すのも選択肢の一つです。

終わりに

今回、MicroCMSによるワンソース・マルチユースを実現した飲食店のメニュー管理のプロトタイプを紹介しました。

季節ごとにメニューを変更するようなカフェなどではウェブやSNS、メニュー表、看板、チラシなどメニュー更新が大変です。

それぞれ個別管理すると、更新漏れや先祖返りなどのミスが起こり得る中、MicroCMSでデータを一元管理し、アウトプットはMicroCMSのAPIを叩いて参照することでワンソース・マルチユースを実現できました。

ヘッドレスCMSのMicroCMSに初めて触れた際、「フロントエンドなしでどうやって表示するの?」という疑問を抱く人も多いです。

しかし、ヘッドレスCMSによって表現の制約から解放されて、あらゆるアウトプットに適合できるようになります。

MicroCMSは国産のヘッドレスCMSで利用事例も多いので、採用しやすいです。

ぜひMicroCMSを活用してワンソース・マルチユースを実現してみてください。

私も今回用意したMicroCMSのプロトタイプを実際に本番稼働させるのを来年の目標にがんばりたいと思います。

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