【ハンズオン】Nuxt.jsとLaravelとLINE Front-end Frameworkを使ってお問い合わせフォームを作ろう！

## はじめに
最近LINE Messaging APIを触ってます。
LINE Front-end Frameworkに関してまだ未経験なので、せっかくなのでこちらも勉強しました。
今回は、Nuxt.jsとLaravelを使ってお問い合わせフォームを作ってみます。

また、LINE Front-end FrameworkはLIFFと呼ばれることが多いのでこれからはLIFFと略します。

## LIFFとは？
公式サイトから引用です。

https://developers.line.biz/ja/docs/liff/overview/#page-title

> LINE Front-end Framework（LIFF）は、LINEが提供するウェブアプリのプラットフォームです。
このプラットフォームで動作するウェブアプリを、LIFFアプリと呼びます。
LIFFアプリを使うと、LINEのユーザーIDなどをLINEプラットフォームから取得できます。
LIFFアプリではこれらを利用して、ユーザー情報を活用した機能を提供したり、ユーザーの代わりにメッセージを送信したりできます。

簡単に言うと、**「LINE内にアプリケーションが作れるぞ！」**というものになります。

## 開発環境
今回は以下の技術を採用しています。

| フロントエンド | バックエンド | インフラ |
| -------------- | ------------ | -------- |
| Nuxt.js        | Laravel      | Docker   |

デプロイに関しては、フロントエンドは`Netlify`を使い、バックエンドは`Heroku`を使用しています。

## 完成コード

https://github.com/ssk9597/LIFF-Nuxt-Laravel-Contact

## それでは一緒にやってみましょう！

### Githubからクローン
Dockerから解説となると長くなるので省略します。

https://github.com/ssk9597/Docker-Laravel-Nuxt-Nginx-MySQL

GitHubにやり方は書いていますが、以下を実行することで環境構築ができます。

```terminal:ターミナル
$ git clone git@github.com:ssk9597/Docker-Laravel-Nuxt-Nginx-MySQL.git
$ cd Docker-Laravel-Nuxt-Nginx-MySQL
$ make nuxt
$ make backend
```

こちらは以下の記事を参考にしてください。

https://qiita.com/Ryo9597/items/ba0838e68da2a73bc55b

`frontend/nuxt.config.js`に関してはこのままだとエラーが発生します。
なのでここだけは以下のコードをコピペしてください。

```javascript:frontend/nuxt.config.js
require('dotenv').config();
const { API_URL } = process.env;

export default {
  head: {
    title: 'frontend',
    htmlAttrs: {
      lang: 'ja',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },

  css: [],

  plugins: [],

  components: true,

  buildModules: [],

  watchers: {
    webpack: {
      poll: true,
    },
  },

  modules: ['@nuxtjs/axios', '@nuxtjs/proxy', '@nuxtjs/dotenv'],

  env: {
    API_URL,
  },

  proxy: {
    '/api': process.env.API_URL,
  },

  axios: {
    baseURL: process.env.API_URL,
    browserBaseURL: process.env.API_URL,
  },

  build: {},
};
```

### NetlifyとHerokuにデプロイを行う
ここが1番の躓きどころかと思います。
自分もいろんな記事を見つつやりました。
なので参考文献を残していきます。

#### Netlify
以下の記事で詳細まで説明されているのでこちらを見てもらえれば問題なくできるかと思います。

https://qiita.com/isihigameKoudai/items/e3b136e9964f1d30d73d

また、ステージング環境に関してもやっておいた方がいいです。

https://blog.microcms.io/netlify-deploy-contexts/

次に問題のHerokuです。

#### Heroku
こちらはMySQLを導入する際に見ておくべきです。

https://qiita.com/yukibe/items/5c1910e259ff4e6498db

今回は複数の`buildpack`が必要になるので以下を参考にしましょう。

https://innovator-japan.hatenablog.com/entry/2018/10/22/131853

`Papertrail`でログも出しておきたいのでこれもやっておきましょう

https://blog.hrendoh.com/setup-logging-to-papertrail-for-laravel6-on-heroku/

最後に通信できているか確認しましょう。
以下の記事の`Axios`の部分を追加しましょう。

https://qiita.com/Ryo9597/items/ba0838e68da2a73bc55b#pagesindexvue%E3%81%AE%E4%BF%AE%E6%AD%A3%E3%81%A7%E6%A4%9C%E8%A8%BC

これで`Hello World!`が表示されればOKです。
うまくいかなかった場合は私に質問していただくか、Teratailなどを使って解決されてください。

### LIFFアプリのタイトルを設定する
まぁ別にやらなくてもいいですw

```javascript:frontend/nuxt.config.js
require('dotenv').config();
const { API_URL } = process.env;

export default {
  target: 'static',
  head: {
    title: 'LIFFで作ったお問い合わせフォーム',
    htmlAttrs: {
      lang: 'ja',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },

  css: [],

  plugins: [],

  components: true,

  buildModules: [],

  watchers: {
    webpack: {
      poll: true,
    },
  },

  modules: ['@nuxtjs/axios', '@nuxtjs/proxy', '@nuxtjs/dotenv'],

  env: {
    API_URL,
  },

  proxy: {
    '/api': process.env.API_URL,
  },

  axios: {
    baseURL: process.env.API_URL,
    browserBaseURL: process.env.API_URL,
  },

  build: {},
};
```

### LIFFアプリにLIFF SDKを組み込む
LIFFアプリには、以下の2種類の方法でLIFF SDKを組み込めます。
・CDNパスを指定する
・npmパッケージを利用する

なぜかわかりませんが、npmパッケージではうまくいかないのでCDNパスで組み込みましょう！

```javascript:frontend/nuxt.config.js
require('dotenv').config();
const { API_URL } = process.env;

export default {
  target: 'static',
  head: {
    title: 'LIFFで作ったお問い合わせフォーム',
    htmlAttrs: {
      lang: 'ja',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
    ],
    script: [{ src: 'https://static.line-scdn.net/liff/edge/2/sdk.js' }],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },

  css: [],

  plugins: [],

  components: true,

  buildModules: [],

  watchers: {
    webpack: {
      poll: true,
    },
  },

  modules: ['@nuxtjs/axios', '@nuxtjs/proxy', '@nuxtjs/dotenv'],

  env: {
    API_URL,
  },

  proxy: {
    '/api': process.env.API_URL,
  },

  axios: {
    baseURL: process.env.API_URL,
    browserBaseURL: process.env.API_URL,
  },

  build: {},
};
```

### LIFFアプリを作成しましょう
こちらの記事に書いてあることをそのままやるだけです。

https://developers.line.biz/ja/docs/liff/getting-started/#page-title

最終的に`LIFF ID`が発行できればOKです。
エンドポイントURLに追加もしましょう。

![スクリーンショット 2021-07-04 16.08.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/3263c074-4741-eadf-678a-5a7e0471e7c1.png)

### LIFFアプリを初期化する
初期化をしましょう。

```vue:frontend/pages/index.vue
<template>
  <div>
  </div>
</template>

<script>
export default {
  mounted() {
    window.liff.init({
      liffId: process.env.LIFF_ID,
    });
  },
};
</script>

<style lang="scss" scoped>
</style>
```

上記で`LIFF_ID`を使っているので`.env`の設定をしましょう。
この`.env`は、`Netlify`にも登録しましょう。

```env:frontend/.env
# API_URL
API_URL = "http://localhost:18080/api"

#LIFF_ID
LIFF_ID = "00000000-aaaaaaaaa"
```

また、`nuxt.config.js`にも追加しましょう。

```javascript:frontend/nuxt.config.js
require('dotenv').config();
const { API_URL, LIFF_ID } = process.env;

export default {
  target: 'static',
  head: {
    title: 'LIFFで作ったお問い合わせフォーム',
    htmlAttrs: {
      lang: 'ja',
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
    ],
    script: [{ src: 'https://static.line-scdn.net/liff/edge/2/sdk.js' }],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
  },

  css: [],

  plugins: [],

  components: true,

  buildModules: [],

  watchers: {
    webpack: {
      poll: true,
    },
  },

  modules: ['@nuxtjs/axios', '@nuxtjs/proxy', '@nuxtjs/dotenv'],

  env: {
    API_URL,
    LIFF_ID,
  },

  proxy: {
    '/api': process.env.API_URL,
  },

  axios: {
    baseURL: process.env.API_URL,
    browserBaseURL: process.env.API_URL,
  },

  build: {},
};
```

ここからフロント部分の作成をしていきます。

### フロントエンドのバリデーションを行う
フロントエンドとバックエンドの両方にバリデーションをつけることが必須です。
フロントエンドにバリデーションがない場合、HTTP通信が走ってしまうのでエラーページへ遷移してしまいます。
また万が一、APIを直接叩かれたときにバックエンド側にバリデーションがないとDBに直接値が入ってしまう可能性があります。
以上の理由からフロントエンドにバリデーションを設定しましょう。

Nuxt.jsには、VeeValidateがあるのでこちらを使って実装していきましょう。

https://vee-validate.logaretm.com

#### インストール方法
##### ①VeeValidateをインストールする
```terminal:ターミナル
$ docker-compose exec front npm install vee-validate --save
```

##### ②プラグインファイルを作成する
```javascript:frontend/plugins/vee-validate.js
import Vue from 'vue';

// 使用する機能
import { ValidationProvider, ValidationObserver, localize, extend } from 'vee-validate';

// エラーメッセージの日本語
import ja from 'vee-validate/dist/locale/ja.json';

// 使用するルール
import { required, email, max } from 'vee-validate/dist/rules';
extend('required', required);
extend('email', email);
extend('max', max);

Vue.component('ValidationProvider', ValidationProvider);
Vue.component('ValidationObserver', ValidationObserver);
localize('ja', ja);
```

##### ③nuxt.config.jsに追記
```javascript:frontend/nuxt.config.js
{
   plugins: [{ src: '@/plugins/vee-validate' }],
   build: {
    transpile: ['vee-validate/dist/rules'],
  },
}
```

これで使えるようになります。

### フォームを作成する
#### フォームラベル
```frontend/components/Atoms/FormLabel.vue
<template>
  <div>
    <div class="form-label">
      <span class="form-label-ttl">{{ title }}</span>
      <span class="form-label-op" :style="styles">{{ option }}</span>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    title: {
      type: String,
      required: true,
    },
    option: {
      type: String,
      default: '必須',
      validator: function(value) {
        return ['必須', '任意'].indexOf(value) !== -1;
      },
    },
  },
  computed: {
    styles() {
      if (this.option === '必須') {
        return {
          backgroundColor: '#d13636',
          color: '#fff',
        };
      } else {
        return {
          backgroundColor: '#757171',
          color: '#e0e0e0',
        };
      }
    },
  },
};
</script>

<style lang="scss" scoped>
.form-label {
  margin-top: 10px;

  &-ttl {
    font-size: 0.8rem;
    font-weight: bold;
    color: #333;
    margin-left: 10px;
  }

  &-op {
    font-size: 0.75rem;
    margin-left: 5px;
    padding: 4px 12px;
    border-radius: 6px;
  }
}
</style>
```

#### フォーム入力欄（1行テキスト）
```frontend/components/Atoms/FormInput.vue
<template>
  <div>
    <ValidationProvider :name="placeholder" :rules="rules" v-slot="{ errors }">
      <input
        class="form-input"
        :type="type"
        :placeholder="placeholder"
        :value="value"
        @input="$emit('input', $event.target.value)"
      />
      <p v-if="errors" class="form-error">{{ errors[0] }}</p>
    </ValidationProvider>
  </div>
</template>

<script>
export default {
  props: {
    rules: {
      type: String,
    },
    type: {
      type: String,
      default: 'text',
      validator: function(value) {
        return ['text', 'email', 'password'].indexOf(value) !== -1;
      },
    },
    placeholder: {
      type: String,
      required: true,
    },
    value: {
      type: String,
      required: true,
    },
  },
};
</script>

<style lang="scss" scoped>
.form-input {
  display: block;
  width: 100%;
  padding: 10px 10px;
  margin: 10px 0;
  color: #333;
  border: solid 1px #ccc;
  border-radius: 6px;
  outline: 0;
  transition: 0.3s;

  &:focus {
    border: 1px solid #00b5ad;
  }
}

.form-error {
  font-size: 0.75rem;
  font-weight: bold;
  color: #d13636;
  padding-bottom: 10px;
}
</style>
```

#### フォーム入力欄（複数行テキスト）
```frontend/components/Atoms/FormTextArea.vue
<template>
  <div>
    <textarea
      class="form-textarea"
      :placeholder="placeholder"
      :value="value"
      @input="$emit('input', $event.target.value)"
    ></textarea>
  </div>
</template>

<script>
export default {
  props: {
    placeholder: {
      type: String,
      required: true,
    },
    value: {
      type: String,
      required: true,
    },
  },
};
</script>

<style lang="scss" scoped>
.form-textarea {
  resize: none;
  display: block;
  width: 100%;
  height: 200px;
  padding: 10px 10px;
  margin: 10px 0;
  color: #333;
  border: solid 1px #ccc;
  border-radius: 6px;
  outline: 0;
  transition: 0.3s;

  &:focus {
    border: 1px solid #00b5ad;
  }
}
</style>
```

#### フォームボタン
```frontend/components/Atoms/FormButton.vue
<template>
  <div class="form-button">
    <button class="button" @click="childClick" :disabled="invalid">送信</button>
  </div>
</template>

<script>
export default {
  props: {
    click: {
      type: Function,
      required: true,
    },
    invalid: {
      type: Boolean,
    },
  },
  methods: {
    childClick() {
      this.click();
    },
  },
};
</script>

<style lang="scss" scoped>
.form-button {
  padding-top: 20px;
  display: flex;
  justify-content: center;
}

.button {
  cursor: pointer;
  width: 200px;
  display: inline-block;
  padding: 0.7rem 0;
  color: #00b5ad;
  background: #fff;
  text-decoration: none;
  user-select: none;
  border: 1px #00b5ad solid;
  border-radius: 20px;
  transition: 0.4s ease;

  &:hover {
    color: #fff;
    background: #00b5ad;
  }
}
</style>
```

#### フォームテンプレート
```frontend/components/Template/Form.vue
<template>
  <ValidationObserver v-slot="{ invalid }">
    <div class="form">
      <div class="form-name">
        <div class="form-name-wrapper">
          <!-- 姓 -->
          <FormLabel :title="'姓'" />
          <FormInput
            :rules="'required|max:10'"
            :type="'text'"
            :placeholder="'姓'"
            :value="firstName"
            @input="firstName = $event"
          />
        </div>
        <div class="form-name-wrapper">
          <!-- 名 -->
          <FormLabel :title="'名'" />
          <FormInput
            :rules="'required|max:10'"
            :type="'text'"
            :placeholder="'名'"
            :value="lastName"
            @input="lastName = $event"
          />
        </div>
      </div>
      <!-- メールアドレス -->
      <FormLabel :title="'メールアドレス'" />
      <FormInput
        :rules="'required|email'"
        :type="'email'"
        :placeholder="'メールアドレス'"
        :value="email"
        @input="email = $event"
      />
      <!-- お問い合わせ内容 -->
      <FormLabel :title="'お問い合わせ内容'" :option="'任意'" />
      <FormTextArea :placeholder="'お問い合わせ内容'" :value="contact" @input="contact = $event" />
      <!-- 送信ボタン -->
      <FormButton :click="submit" @submit="submit" :invalid="invalid" />
    </div>
  </ValidationObserver>
</template>

<script>
import FormInput from '@/components/Atoms/FormInput.vue';
import FormLabel from '@/components/Atoms/FormLabel.vue';
import FormTextArea from '@/components/Atoms/FormTextArea.vue';
import FormButton from '@/components/Atoms/FormButton.vue';

export default {
  components: {
    FormInput,
    FormLabel,
    FormTextArea,
    FormButton,
  },
  data() {
    return {
      firstName: '',
      lastName: '',
      email: '',
      contact: '',
    };
  },
  methods: {
    async submit() {
      try {
        if (!liff.isInClient()) {
          console.log('外部ブラウザまたはLINE内ブラウザで動作させている');
          // APIを叩く
          await this.$axios.$post('/contacts', {
            name: `${this.firstName} ${this.lastName}`,
            email: this.email,
            contact: this.contact,
          });
          return redirect('/');
        } else {
          console.log('LIFFブラウザで動作させている');
          // APIを叩く
          await this.$axios.$post('/contacts', {
            name: `${this.firstName} ${this.lastName}`,
            email: this.email,
            contact: this.contact,
          });
          // メッセージを送る
          await liff.sendMessages([
            {
              type: 'text',
              text: `お問い合わせありがとうございます。\n\n---お問い合わせ内容---\n姓：${this.firstName}\n名：${this.lastName}\nメールアドレス：${this.email}\n内容：${this.contact}`,
            },
          ]);
          // アプリを閉じる
          await liff.closeWindow();
        }
      } catch (err) {
        console.log(err);
        alert(err);
      }
    },
  },
};
</script>

<style lang="scss" scoped>
.form {
  display: flex;
  flex-direction: column;
  justify-content: center;
  max-width: 400px;
  width: 100%;
  margin: 0 auto;

  &-name {
    display: flex;
    justify-content: space-between;

    &-wrapper {
      width: calc(50% - 10px);
    }
  }
}
</style>
```

これでフロントエンドは終了です。

### APIを作成する
#### モデル&マイグレーションを作成
```terminal:ターミナル
$ php artisan make:model Models/Contact --migration
```

ではマイグレーションファイルから作成します。

```php:api/database/migrations/XXXX_XX_XX_XXXXXX_create_contacts_table.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateContactsTable extends Migration
{
  /**
   * Run the migrations.
   *
   * @return void
   */
  public function up()
  {
    Schema::create('contacts', function (Blueprint $table) {
      // id
      $table->bigIncrements('id')->comment("ID");
      // 名前
      $table->string("name", 20)->comment("名前");
      // メールアドレス
      $table->string("email", 255)->comment("メールアドレス");
      // お問い合わせ内容
      $table->text("contact", 65535)->comment("お問い合わせ内容");
      // タイムスタンプ
      $table->timestamps();
    });
  }

  /**
   * Reverse the migrations.
   *
   * @return void
   */
  public function down()
  {
    Schema::dropIfExists('contacts');
  }
}
```

次にモデルファイルを修正します。

```php:api/app/Models/Contact.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Contact extends Model
{
  protected $fillable = [
    'name', 'email', 'contact',
  ];
}
```

### バックエンドのバリデーションを作成する
```terminal:ターミナル
$ php artisan make:request StoreContact
```

ファイルが作成されたので修正しましょう。

```php:api/app/Http/Requests/StoreContact.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreContact extends FormRequest
{
  /**
   * Determine if the user is authorized to make this request.
   *
   * @return bool
   */
  public function authorize()
  {
    return true;
  }

  /**
   * Get the validation rules that apply to the request.
   *
   * @return array
   */
  public function rules()
  {
    return [
      "name" => "required|string|max:20",
      "email" => "required|email|max:255",
      "contact" => "max:65535"
    ];
  }
}
```

### コントローラーを作成する
```php:api/app/Http/Controllers/ContactController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

// Models
use App\Models\Contact;

// Validation
use App\Http\Requests\StoreContact;

class ContactController extends Controller
{
  public function index()
  {
    return Contact::all();
  }

  public function store(StoreContact $request)
  {
    // インスタンス化
    $contact = new Contact;

    // 値をDBに保存する
    $contact->fill($request->all())->save();
  }
}
```

### 最後にルーティング処理をしましょう
```php:api/routes/api.php
<?php

use Illuminate\Http\Request;

/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
*/
// contacts
Route::get("/contacts/index", "ContactController@index");
Route::post("/contacts", "ContactController@store");
```

これで完成です。

## 動作確認
正しく動いていますね！

![iOS の画像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/38ba6a6b-937f-54e2-9b40-4692a4c4ba16.png)

![iOS の画像 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/0b088d6b-71fd-1374-a46e-68e126d1911d.png)

APIのデプロイ先がHerokuかつ、MySQLなので動作が重くすぐには反映されません。
なので、`Sequel Pro`を使って確認します。

![スクリーンショット 2021-07-04 16.44.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/8725df89-802e-fc34-63bf-bddebe3ebdd5.png)

しっかり値が入っていますね！
確認できました。

## 終わりに
Herokuへのデプロイ以外はあまり難しいところはないかと思います。
いつまでもHerokuを使うのは良くないので次はLambdaにデプロイします。
Laravelの場合は、`Custom Runtime`が必要になるので次はこちらをまとめていきます！