Nuxt.js × GAS Execution API で同人頒布会向け予約システムをごにょごにょする PWA を作る

# はじめに
「[GASでQRコードを使った同人頒布会向け予約システムを作った話](https://qiita.com/blachocolat/items/47d324e91339c60e5397)」の続きです。

![reserview_overview.png](https://qiita-image-store.s3.amazonaws.com/0/240213/8176074f-5b2b-4d4f-895a-702f10045aae.png)

半年ほど前、日本最大級のアナログゲーム頒布会である「[ゲームマーケット2018春](http://gamemarket.jp/)」で、上記の予約システムを実際に運用してみたところ、

> - （こちらからの声かけ後を含め）QRコードを提示してくれたのは6割弱
>   - 0.5割くらいの人がガラケーないしキャリアメールのため、QRコード自体を受信できていない

という問題にぶち当たりました。
QR コードが提示されなかった場合、スマホの Google スプレッドシートアプリから予約番号 or 名前を検索していましたが、いまいち操作性がよろしくない[^1]。

[^1]: “\[メニュー\] > \[検索と置換\] > (予約番号 or 名前に応じて IME 切り替え >) 検索クエリを入力 > 該当行の \[購入日時\] セルにマーク”ってフローがめんどくさいし、他のセルを間違えて上書きしてしまうリスクもありますね。

というわけで、いっそスプレッドシートを外部 API 化して、スマホアプリから予約情報の検索＆購入確定できるようにして、なんならアプリに QR コード読み取り機能も埋め込んじゃおう、というのが今回の趣旨です。

## PWA (Progressive Web Apps)
サークル内々で使うアプリのため、アプリストアに上げるようなものでもないので、PWA (Progressive Web Apps) と呼ばれる技術仕様を用いて開発しました。
PWA は（私の理解で）ざっくり言うと「Web ページをローカルに保存してネイティブアプリっぽく使える」技術仕様で、

- ネイティブアプリと比較すると……ストアの審査が不要、クロスプラットフォーム対応が容易
- Web アプリと比較すると……キャッシュを用いてオフラインでも動作可、Push 通知が利用可[^2]

のような特長があります（参考：[いまさら聞けないPWAとAMP](https://qiita.com/edwardkenfox/items/4c0b9550ffa48c1f0445)）。

[^2]: 2018年10月時点では、iOS では利用不可の模様（なので、クロスプラットフォーム対応が容易、というのもあやしい）です。

ひと昔前だとキャッシュの管理などをゴリゴリ書くのがとても面倒だったみたいですが、Web フレームワークの一つである Nuxt.js を用いた場合、簡単に Webアプリを PWA 化することができるそうです。

# 開発手順
せっかくなので備忘録的に Nuxt.js × GAS Execution API で PWA を開発するポイントをまとめました。

## 1. Nuxt.js プロジェクトの作成
[vue-cli](https://github.com/vuejs/vue-cli) を使って、テンプレートから Nuxt.js プロジェクトを作成します。
ここでは、プロジェクト名を `reserview-client` としました。

```
$ vue init nuxt/starter reserview-client
$ cd reserview-client
$ npm install
```

`npm run dev` 実行時、Nuxt 2.x ではエラーが発生するみたいなので、以下のように `nuxt.config.js` を修正します。

```diff:nuxt.config.js
  module.exports = {
    ...
    build: {
      /*
      ** Run ESLint on save
      */
-     extend (config, { isDev, isClient }) {
-       if (isDev && isClient) {
+     extend (config) {
+       if (process.server && process.browser) {
          config.module.rules.push({
            enforce: 'pre',
            test: /\.(js|vue)$/,
            loader: 'eslint-loader',
            exclude: /(node_modules)/
          })
        }
      }
    }
  }
```

## 2. GAS Execution API の有効化
Google スプレッドシートから \[ツール\] > \[スクリプト エディタ\] を開き、以下のような `scripts/API.gs` を作成します。
＃実際は、予約一覧を取得するコードをゴリゴリ書いていますが、ここでは割愛します。

[前回の記事](https://qiita.com/blachocolat/items/47d324e91339c60e5397)で [clasp](https://github.com/google/clasp) を導入している場合は、ローカル環境で `scripts/API.js` を作成し、`clasp push` しても構いません。

```js:scripts/API.gs
function getReservations () {
    // dummy data
    return JSON.stringify({
        id: '541603',
        reservedAt: '2018-11-07T19:38:26.572Z',
        // purchasedAt: '2018-11-25T11:19:52.398Z'
        name: 'ぶらちょこ'
    });
}
```

次に \[リソース\] > \[Cloud Platform プロジェクト…\] > \[このスクリプトが現在関連付けられているプロジェクト:\] のリンクから Google Cloud Platform Console を開きます。
\[メニュー\] > \[API とサービス\] > \[ライブラリ\] の検索ボックスから "Apps Script API" を探し、\[有効にする\] を選択します。
![fig2.png](https://qiita-image-store.s3.amazonaws.com/0/240213/cfe1d3f4-82bb-25ad-9a6c-c6c558d1bd1e.png)

## 3. OAuth 認証情報の作成
Google Cloud Platform Console の \[メニュー\] > \[API とサービス\] > \[認証情報\] を開き、\[認証情報\] タブ > \[認証情報を作成\] > \[OAuth クライアント ID\] を選択します。

以下のように各項目を設定し、OAuth クライアント ID を\[作成\] します。
\[承認済みの JavaScript 生成元\] には、後のステップでホストされた Web ページ の URL を設定しますが、とりあえずローカル環境で試したいというだけであれば、`http://localhost:<ポート番号>` のみで OK です。
![fig3.png](https://qiita-image-store.s3.amazonaws.com/0/240213/3f044613-0828-ec05-5def-5091fb48cd8e.png)

<!--
続けて [認証情報\] タブ > \[認証情報を作成\] > \[API キー\] を選択し、API キーを作成します。
本番環境で API キーを使用する場合には、表示されたダイアログから \[キーの制限\] に進み、HTTP リファラー などで使用できるアプリケーションを制限しておきましょう。
-->

その後、スクリプトエディタに戻り、\[公開\] > \[実行可能 API として導入…\] を開きます。
以下のように各項目を設定して \[配置\] すると、GAS を外部 API として利用できるようになります。
![fig1.png](https://qiita-image-store.s3.amazonaws.com/0/240213/2c547dad-527c-43c5-ad06-7ccb3b312ed0.png)

## 4. GAS Execution API の実行
Web ブラウザ（JavaScript）から GAS Execution API を利用するには、以下の手順を踏む必要があります。
[公式ドキュメント](https://cloud.google.com/compute/docs/tutorials/javascript-guide?hl=ja)のチュートリアルの方がかなり親切に書かれているので、ご参考までに。

1. OAuth 認証
2. Google アカウント認証 
3. Apps Script API (`gapi.client.script`) の読み込み
4. Apps Script API の実行

```html:pages/index.vue
...
<script>
const CLIENT_ID = '<「3. OAuth 認証情報の作成」で作成した OAuth クライアント ID>'
const SCRIPT_ID = '<[ファイル] > [プロジェクトのプロパティ] > [情報] > [プロジェクト キー]>'
// [ファイル] > [プロジェクトのプロパティ] > [スコープ] に記載されたスコープ
const SCOPES = [
  'https://www.googleapis.com/auth/drive',
  'https://www.googleapis.com/auth/script.external_request',
  'https://www.googleapis.com/auth/script.scriptapp',
  'https://www.googleapis.com/auth/script.send_mail',
  'https://www.googleapis.com/auth/spreadsheets',
  'https://www.googleapis.com/auth/userinfo.email'
]

export default {
  head: () => ({
    script: [
      { src: 'https://apis.google.com/js/client.js' }
    ]
  }),
  async mounted () {
    // 1. Apps Script API の読み込み
    await gapi.client.load('https://www.googleapis.com/discovery/v1/apis/script/v1/rest')

    // 2. OAuth 認証
    gapi.client.init({
      clientId: CLIENT_ID,
      scope: SCOPES.join(' ')
    }).then(async () => {
      // 3. Google アカウント認証
      if (!gapi.auth2.getAuthInstance().isSignedIn.get()) {
        await gapi.auth2.getAuthInstance().signIn()
      }
      
      // 4. Apps Script API の実行
      const result = await gapi.client.script.scripts.run({
        scriptId: SCRIPT_ID,
        resource: {
          function: 'getReservations',
          parameters: []
        }
      })

      // 5. 結果をごにょごにょ
      // JSON.parse(response.result.response.result)
    }, (e) => { console.error(e) })
  }
}
</script>
```

## 5. 外部プラグインの追加
Web ページに QR コードの読み取り機能を追加するため、[vue-qrcode-reader](https://github.com/gruhn/vue-qrcode-reader) を導入します[^3]。

[^3]: 既存 JS ライブラリの Wrapper とはいえ、Vue.js ライブラリには“なさそうであるもの”がそれなりに見つかる印象です（個人の感想です）。

```
$ npm install --save vue-qrcode-reader
```

```html:pages/index.vue
<template>
  ...
  <qrcode-reader
    :track="false"
    @decode="onDecode"
  </qrcode-reader>
  ...
</template>

<script>
...
export default {
  ...
  methods: {
    onDecode (decodedString) {
      // 購入確定する処理とか
    }
  }
}
</script>
```

Nuxt.js は、デフォルトでは SSR (Server Side Rendering) で動作するため、Web ブラウザ上での動作のみを想定しているライブラリ（`document` 変数を使っているなど）は正常に動作しません。
[公式 FAQ](https://ja.nuxtjs.org/guide/plugins/#%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E3%82%B5%E3%82%A4%E3%83%89%E3%81%AE%E3%81%BF%E3%81%A7%E3%81%AE%E4%BD%BF%E7%94%A8) にある通り、クライアントサイドでのみプラグインを使用する設定にしましょう。

```js:plugins/vue-qrcode-reader.js
import Vue from 'vue'
import VueQrcodeReader from 'vue-qrcode-reader'

Vue.use(VueQrcodeReader)
```

```diff:nuxt.config.js
  module.exports = {
    ...
+   plugins: [
+     { src: '~/plugins/vue-qrcode-reader', ssr: false }
+   ]
  }
```

Web ページの読み込み速度は（おそらく）下がりますが、SSR 自体をやめて SPA (Single Page Application) にしてしまうのも手です。

```diff:nuxt.config.js
  module.exports = {
    ...
+   mode: 'spa'
  }
```

## 6. Web ページの PWA 化
Nuxt.js プロジェクトを PWA 化するのに便利な [PWA Module](https://github.com/nuxt-community/pwa-module) が提供されているので、これを利用します。

```
$ npm install --save @nuxtjs/pwa
```

`nuxt.config.js` に以下のような manifest を追加し、アプリアイコンとして適当な `static/icon.png`（推奨サイズは 512×512px）を配置しておけば、PWA として認識されるようになります[^5]。

[^5]: 本来、アプリアイコンとして様々なサイズ（120x120px, 144x144px, 192x192px, ...）の画像ファイルを用意すべきところを、PWA Module では `static/icon.png` をもとにリサイズしたものを設定しているようです。リサイズによる意図しない文字のつぶれなどが気になる方は、個別に画像ファイルを用意して `manifest.icons` を設定することもできます（参考：[PWA対応をするためにやった最低限のこと](https://ajike.github.io/progressive-web-application/)）。

```diff:nuxt.config.js
  module.exports = {
    ...
+   modules: [
+     '@nuxtjs/pwa'
+   ],
+   manifest: {
+     name: 'こぐまやん.app',        // スプラッシュ画面に表示されるアプリの名前
+     short_name: 'こぐまやん',      // ホーム画面に追加されるアイコンに付される名前
+     lang: 'ja',
+     theme_color: '#795548',      // ステータスバーの色（Android のみ？）
+     background_color: '#ffffff'  // スプラッシュ画面の背景色
+   }
  }
```

## 7. Firebase Hosting へのデプロイ
スマホからアクセスできるように、ホスティングサービスを使って Web ページをインターネットに公開してやります。
無料枠のあるホスティングサービスだけでも、[Github Pages](https://pages.github.com/) やら [Netlify](https://www.netlify.com/) やらいくらでも選択肢はありますが、今回は天下の Google 様が提供する [Firebase](https://firebase.google.com/) を選びました[^4]。

[^4]: 他サービスと比較して、なんとなく可用性が高そうだと思ったため。

[Firebase CLI](https://github.com/firebase/firebase-tools) をインストールしたら、`firebase login` コマンドで Google アカウント認証を通します。

```
$ npm install -g firebase-tools
$ firebase login
```

Google アカウント認証が無事通ったら、Web ブラウザで [Firebase Console](https://console.firebase.google.com) を開き、\[プロジェクトを追加\] します。
\[プロジェクト名\] は任意ですが、ホストされた Web ページの URL は `https://<プロジェクト ID>.firebaseapp.com` となるため、なるべく覚えやすいものがよさそうです。
![fig4.png](https://qiita-image-store.s3.amazonaws.com/0/240213/d7016a58-a18c-eece-b940-e112d9cd8c30.png)

次に `firebase init` コマンドを実行し、以下のように対話形式で Firebase Hosting の設定を行います。

```
$ firebase init

     🔥🔥🔥🔥🔥🔥🔥🔥 🔥🔥🔥🔥 🔥🔥🔥🔥🔥🔥🔥🔥  🔥🔥🔥🔥🔥🔥🔥🔥 🔥🔥🔥🔥🔥🔥🔥🔥     🔥🔥🔥     🔥🔥🔥🔥🔥🔥  🔥🔥🔥🔥🔥🔥🔥🔥
     🔥🔥        🔥🔥  🔥🔥     🔥🔥 🔥🔥       🔥🔥     🔥🔥  🔥🔥   🔥🔥  🔥🔥       🔥🔥
     🔥🔥🔥🔥🔥🔥    🔥🔥  🔥🔥🔥🔥🔥🔥🔥🔥  🔥🔥🔥🔥🔥🔥   🔥🔥🔥🔥🔥🔥🔥🔥  🔥🔥🔥🔥🔥🔥🔥🔥🔥  🔥🔥🔥🔥🔥🔥  🔥🔥🔥🔥🔥🔥
     🔥🔥        🔥🔥  🔥🔥    🔥🔥  🔥🔥       🔥🔥     🔥🔥 🔥🔥     🔥🔥       🔥🔥 🔥🔥
     🔥🔥       🔥🔥🔥🔥 🔥🔥     🔥🔥 🔥🔥🔥🔥🔥🔥🔥🔥 🔥🔥🔥🔥🔥🔥🔥🔥  🔥🔥     🔥🔥  🔥🔥🔥🔥🔥🔥  🔥🔥🔥🔥🔥🔥🔥🔥

You're about to initialize a Firebase project in this directory:

  *****/reserview-client

? Which Firebase CLI features do you want to setup for this folder? Press Space 
to select features, then Enter to confirm your choices. Hosting: Configure and d
eploy Firebase Hosting sites

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add, 
but for now we'll just set up a default project.

? Select a default Firebase project for this directory: <プロジェクト名> (<プロジェクトID>)
i  Using project <プロジェクト名> (<プロジェクト ID>)

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? dist
? Configure as a single-page app (rewrite all urls to /index.html)? Yes
✔  Wrote dist/index.html

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...
i  Writing gitignore file to .gitignore...

✔  Firebase initialization complete!
```

`Firebase initialization complete!` と表示されたら Firebase Hosting の設定は完了です。
以下のコマンドを実行し、Web ページを公開しましょう！

```
$ npm run generate
$ firebase deploy
```

## 8. PWA のインストール
スマホの Web ブラウザで `https://<プロジェクト ID>.firebaseapp.com` を開き、PWA をインストールします。
インストール方法は、ホーム画面にショートカットを作成する方法とまったく同じです。

- iOS (Safari) の場合：\[追加・共有・保存\] > \[ホーム画面に追加\] を選択
- Android (Chrome) の場合：\[メニュー\] > \[ホーム画面に追加\] を選択

インストールが完了すると、ホーム画面にアイコンが追加されます。

# できたもの
![output.gif](https://qiita-image-store.s3.amazonaws.com/0/240213/c4bf3b24-4b27-f971-7938-3af60c2a281f.gif)
**便利っぽい！！！！**

## 補足（＋蛇足）
- 一番ハマったのは、GAS の OAuth 認証、、、困ったら、スクリプトエディタの \[公開\] > \[実行可能 API として導入…\] > \[更新\] してみるのが吉。
- GAS の実行速度はそこらへんの Web API と比べると見劣りしますが、Google フォーム との親和性の高さなどを踏まえると、選択肢の一つとしては“あり”かなと思いました。
  - Google スプレッドシート（いわゆる Excel 的 UI）が非エンジニアでも閲覧・編集しやすい点も◎。
- 今回のアプリでは、UI フレームワークに [Vuetify](https://github.com/vuetifyjs/vuetify) を用いました。Material Design 風の画面を簡単に作れます。
- この記事を見て「面白いことやってんな！」と思ったアナログゲーム愛好家の方は、ぜひ 11/24(土)-25(日) に東京ビッグサイトで開催される「[ゲームマーケット2018秋](http://gamemarket.jp/)」にお越しください！
  - 「[【日-F10】こぐまやん](http://gamemarket.jp/booth/gm2529/)」および「[【両A-46】18会](http://gamemarket.jp/booth/gm2833/)」ブースにて、アプリを体験していただけます（ダイレクトマーケティング）。

# 参考記事
- [いまさら聞けないPWAとAMP](https://qiita.com/edwardkenfox/items/4c0b9550ffa48c1f0445)
- [Nuxt.js で作った静的サイトを PWA 化する](https://qiita.com/QUANON/items/880eaa43c1d2f55f5b4d)
- [【GAS】Execution APIを使ってJavaScriptからGASにアクセスする](https://qiita.com/ShishidoToru/items/67a7cad6b997e316cb20)
- [Firebase Hosting でWebサイトを公開する方法](https://qiita.com/gupuru/items/25a6722f6f802d3a5250)

# 注意事項
- 本プロジェクトの利用は、自己責任でお願いします。
- 「QRコード」は、㈱デンソーウェーブの登録商標です。