世界を旅して暮らしたい放浪エンジニアブログ

Nuxt.jsとNetlify Lambda Functionsを使って問い合わせSlack通知を実装

前回の NetlifyでLambda Functionsを試す で初めてLambda Functionsを利用しましたが、今回は本ブログの問い合わせ機能に組み込んでいきます。問い合わせの内容はSlackに通知するようにします。

[ 目次 ]

はじめに

こんばんは、香港に住んでいるWEBデベロッパーのなかむ(@nakanakamu0828)です。

前回の NetlifyでLambda Functionsを試す で初めてLambda Functionsを利用しましたが、今回は本ブログの問い合わせ機能に組み込んでいきます。問い合わせの内容はSlackに通知するようにしていきます。

前回の復習も兼ねて進めていきます。

"netlify-lambda"のインストール

netlify-lambda はローカルで確認するためのサーバーを提供しています。
また、NetlifyにLambdaをデプロイする時のビルドにも利用するコマンドです。

■ npmを利用する場合

$ npm install netlify-lambda --save

■ yarnを利用する場合

$ yarn add netlify-lambda

実装に必要なライブラリをインストール

axios

API通信にはaxiosを利用します。
フロント、サーバーどちらでも利用します。

■ npmを利用する場合

$ npm install @nuxtjs/axios --save

■ yarnを利用する場合

$ yarn add @nuxtjs/axios

email-validator

サーバー側のメールアドレスチェックに利用します

■ npmを利用する場合

$ npm install email-validator --save

■ yarnを利用する場合

$ yarn add email-validator

vee-validate

フロント側のバリデーションに利用します

■ npmを利用する場合

$ npm install vee-validate --save

■ yarnを利用する場合

$ yarn add vee-validate

Lambdaの構成を整理

Lambdaに必要となるファイルを用意していきます。
まずは、Netlifyのデプロイ設定に必要なnetlify.tomlをプロジェクトルートに作成します。

netlify.toml

[build]
  Command = "yarn generate"
  functions = "functions"
  publish = "dist"

functionsのディレクトリ設定は functionsとしました。
この場合、/.netlify/functions/{function_name} というURLで公開されます。
ビルドコマンドとpublishディレクトリは、本ブログで利用するNuxt.jsの設定になります。
プロフィールサイトの時はnpmを利用したbuildコマンドでしたが、今回はyarnを利用します。

lambdaを作成(contact.js)

今回は、lambdaディレクトリにLambdaのソースを配置します。
問い合わせ処理を行う contact.js を作成します。
Slackへの通知は、Webhook URLを利用します。

SlackのWebhook URL取得手順

こちらを参考にURLを生成してください。

'use strcit';

const axios = require('axios')
const emailValidator = require("email-validator")

exports.slack = (message) => {
    let options = {
        method: 'post',
        baseURL: process.env.SLACK_URL, // SLACK_URL にWebhookのURLを環境変数として設定
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
        },
        data: `payload={
            "text": "${message}",
        }`
    };
    return axios.request(options).then((result)=>{
        return result.data
    })
}

exports.handler = (event, context, callback) => {
    if (event.httpMethod !== 'POST' || !event.body) {
        callback(null, {
            statusCode: 404,
            body: ""
        })
        return
    }

    const data = JSON.parse(event.body);

    let error = []
    if (!data.name) error.push({ name: '氏名を入力して下さい' })
    if (!data.title) error.push({ title: 'タイトルを入力して下さい' })
    if (data.email && !emailValidator.validate(data.email)) error.push({ email: 'メールアドレスが正しくありません' })
    if (!data.body) error.push({ body: '問い合わせ内容を入力して下さい' })
    if (error.length) {
        callback(null, {
            statusCode: 400,
            headers: {
                'Content-type': 'application/json'
            },
            body: JSON.stringify({ error: error })
        })
        return
    }

    const message = `
【 氏名 】
${data.name}

【 メールアドレス 】
${data.email}

【 タイトル 】
${data.title}

【 問い合わせ内容 】
${data.body}
`;

    exports.slack(message).then((result) => {
        callback(null, {
            statusCode: 200,
            body: ""
        })
    })
}

問い合わせの項目は

  • 氏名(必須)
  • メールアドレス(任意)
  • タイトル(必須)
  • 問い合わせ内容(必須)

の4項目としました。

ビルドコマンドの修正(package.json)

Lambdaのビルドも必要になるので、package.jsonのビルドコマンドを修正します。
以下のように修正してください。

{
  ....
  "scripts": {
    "dev": "nuxt",
    "lambda": "netlify-lambda serve lambda",
    "build": "netlify-lambda build && nuxt build",
    "start": "nuxt start",
    "generate": "netlify-lambda build && nuxt generate",
    "lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
    "precommit": "npm run lint"
  },
  ....
}

buildコマンドに netlify-lambda build lambda を追加しています。
また、ローカルで確認できるようにlambdaコマンドも追加しました。

ローカル確認

以下のコマンドを実行してローカルからLambdaを確認しましょう。
SLACK_URL など必要となる環境変数を設定してからlambdaを起動してください。

$ yarn lambda

起動ができたら以下のcurlコマンドでAPI通信を試してみましょう。

$ curl -X POST \
  http://localhost:9000/contact \
  -H 'content-type: application/json' \
  -d '{
	"name": "山田太郎",
	"title": "仕事相談・依頼",
	"email": "nakamura@gmail.com",
	"body": "はじめまして、こんにちは"
}'

成功時は、ステータスコード:200でbodyに何も返していません。
Slackの通知ができるか確認してみてください。

フロント側の実装

モーダルで問い合わせ画面を表示していきます。
以下が問い合わせ画面のイメージです。ヘッダーの"Contact"リンクを押下することで、問い合わせ画面が開くようにします。

Blog 問い合わせ画面 キャプチャ

コンポーネントとしてcomponents/Modal/Contact.vueを用意

<template>
    <div class="modal" :class="{'is-active': modal}">
        <div class="modal-background" @click="closeModal"></div>
        <div class="modal-content">
            <section class="modal-card-body">
                <div class="has-text-centered">
                    <h2 class="title has-text-dark is-underline is-text-font-quicksand">Contact</h2>
                    <p class="subtitle has-text-dark is-7">お問い合わせ</p>
                </div>

                <div v-if="input">
                    <form
                        @submit.prevent="validateBeforeSubmit"
                        novalidate
                    >
                        <div class="columns">
                            <div class="column is-offset-1 is-10">
                                <div class="field">
                                    <label class="label">氏名</label>
                                    <div class="control has-icons-left">
                                        <input
                                            class="input"
                                            name="name"
                                            type="text"
                                            v-model="name"
                                            v-validate="'required'"
                                            data-vv-as="氏名"
                                            :class="{ 'is-danger': errors.has('name') }"
                                        >
                                        <span class="icon is-small is-left">
                                            <i class="fas fa-user"></i>
                                        </span>
                                    </div>
                                    <p class="help is-danger" v-if="errors.has('name')">
                                        {{ errors.first('name') }}
                                    </p>
                                </div>

                                <div class="field">
                                    <label class="label">メールアドレス</label>
                                    <div class="control has-icons-left">
                                        <input
                                            class="input"
                                            name="email"
                                            type="email"
                                            v-model="email"
                                            v-validate="'email'"
                                            data-vv-as="メールアドレス"
                                            :class="{ 'is-danger': errors.has('email') }"
                                        >
                                        <span class="icon is-small is-left">
                                            <i class="fas fa-envelope"></i>
                                        </span>
                                    </div>
                                    <p class="help is-danger" v-if="errors.has('email')">
                                        {{ errors.first('email') }}
                                    </p>
                                    <p class="help">
                                        ※ 返信が必要な方はメールアドレスをご入力ください
                                    </p>
                                </div>

                                <div class="field">
                                    <label class="label">種別</label>
                                    <div class="control">
                                        <div class="select is-fullwidth">
                                            <select
                                                name="title"
                                                v-model="title"
                                                v-validate="'required'"
                                                data-vv-as="種別"
                                                :class="{ 'is-danger': errors.has('title') }"
                                            >
                                                <option>メンター依頼</option>
                                                <option>仕事相談・依頼</option>
                                                <option>その他</option>
                                            </select>
                                        </div>
                                    </div>
                                    <p class="help is-danger" v-if="errors.has('title')">
                                        {{ errors.first('title') }}
                                    </p>
                                </div>

                                <div class="field">
                                    <label class="label">問い合わせ内容</label>
                                    <div class="control">
                                        <textarea
                                            name="body"
                                            v-model="body"
                                            class="textarea"
                                            placeholder="お問い合わせ内容を入力してください。"
                                            v-validate="'required'"
                                            data-vv-as="問い合わせ内容"
                                            :class="{ 'is-danger': errors.has('body') }"
                                        ></textarea>
                                    </div>
                                    <p class="help is-danger" v-if="errors.has('body')">
                                        {{ errors.first('body') }}
                                    </p>
                                </div>

                                <button class="button is-success is-large is-rounded is-fullwidth">
                                    送信
                                </button>
                            </div>
                        </div>
                    </form>
                </div>
                <div v-else>
                    <div class="columns m-t-40 has-text-centered">
                        <div class="column is-offset-1 is-10">
                            <h3 class="title has-text-dark is-4">
                                お問い合わせありがとうございます
                            </h3>
                            <p class="subtitle has-text-dark is-6 m-t-20 m-b-50">
                                この度はお問い合わせいただきありがとうございます。<br>
                                メールアドレスを入力頂いた方には、後ほどご連絡をさせていただきますので、今しばらくお待ちください。
                            </p>
                            <button
                                class="button is-success is-large is-rounded is-fullwidth"
                                @click="closeModal"
                            >
                                閉じる
                            </button>
                        </div>
                    </div>
                </div>
            </section>
        </div>
        <button class="modal-close is-large" @click="closeModal"></button>
    </div>
</template>

<script>
import axios from 'axios';

axios.defaults.baseURL = process.env.VUE_APP_API_BASE_URL;

export default {
  name: 'ContactModal',
  props: {
  },
  computed: {
    modal() {
      return this.$store.getters.isContactModal;
    },
  },
  methods: {
    closeModal() {
      this.input = true;
      this.$store.dispatch('closeContactModal');
    },
    validateBeforeSubmit() {
      this.$validator.validateAll().then((result) => {
        if (result) {
          axios.post('/contact', {
            name: this.name,
            email: this.email,
            title: this.title,
            body: this.body,
          })
            .then(() => {
              this.input = false;
              this.name = null;
              this.email = null;
              this.title = null;
              this.body = null;
            });
        }
      });
    },
  },
  data: () => ({
    input: true,
    name: null,
    email: null,
    title: 'メンター依頼',
    body: null,
  }),
};
</script>

<style scoped>
</style>

Vuex(store/index.js)でモーダルの表示状態を管理

import Vuex from 'vuex';

const store = () => new Vuex.Store({
  state: {
    contactModal: false,
  },
  mutations: {
    toggleContactModal(state) {
      state.contactModal = !state.contactModal;
      document.querySelector('html').classList.toggle('is-clipped');
    },
    closeContactModal(state) {
      state.contactModal = false;
      document.querySelector('html').classList.remove('is-clipped');
    },
  },
  actions: {
    toggleContactModal: ({ commit }) => {
      commit('toggleContactModal');
    },
    closeContactModal: ({ commit }) => {
      commit('closeContactModal');
    },
  },
  getters: {
    isContactModal: state => state.contactModal,
  },
})


export default store

layouts/default.vueにてモーダルのコンポーネントを読み込みます

<template>
  <div>
    <Header/>
    <nuxt/>
    <Footer/>
    <ButtonPageTop />
    <ModalContact />
  </div>
</template>

<script>
import Header from '~/components/Header.vue'
import Footer from '~/components/Footer.vue'
import ButtonPageTop from '~/components/Button/PageTop.vue'
import ModalContact from '~/components/Modal/Contact.vue'

export default {
  components: {
    Header,
    Footer,
    ButtonPageTop,
    ModalContact,
  }
}
</script>

components/Header.vueを変更して、"Contact"リンクからモーダルを表示する

<template>
  <nav class="navbar is-primary" :class="{'is-fixed-top is-fadein': scrollY > 100}">
    <div class="container">
      <div class="navbar-brand">
        <router-link to="/" class="navbar-item is-block">
          <div class="font-leckerli-one navbar-brand__title">
            Nakamu Blog
          </div>
          <div class="subtitle is-size-7"> 世界を旅して暮らしたい放浪エンジニア</div>
        </router-link>

        <span class="navbar-burger burger" :class="{'is-active': drawer}" @click="drawer = !drawer">
          <span></span>
          <span></span>
          <span></span>
        </span>
      </div>
      <div class="navbar-menu" :class="{'is-active': drawer}">
        <div class="navbar-end">
          <a
            href="https://nakamu.life/"
            class="navbar-item is-text-font-quicksand"
            target="_blank"
          >
            About
          </a>
          <router-link
            to="/"
            class="navbar-item is-active is-text-font-quicksand"
          >
            Blog
          </router-link>
          
          <!-- リンク押下で、toggleContactModalメソッドを呼び出す -->
          <a
            href="javascript:void(0)"
            class="navbar-item is-text-font-quicksand"
            @click="toggleContactModal"
          >
            Contact
          </a>

        </div>
      </div>
    </div>
  </nav>
</template>

<script>
export default {
  name: 'Header',
  props: {
  },
  data: () => ({
    drawer: false,
    scrollY: 0,
  }),
  mounted() {
    window.addEventListener('scroll', this.handleScroll);
  },
  methods: {
    handleScroll() {
      this.scrollY = window.scrollY;
    },
    // メソッドを追加
    toggleContactModal() {
      this.$store.dispatch('toggleContactModal');
    },
  },
};
</script>

vee-validate, axiosのセットアップ

plugins/vee-validate.jsを用意

import Vue from 'vue';
import VeeValidate, { Validator } from 'vee-validate';
import ja from 'vee-validate/dist/locale/ja';

Validator.localize('ja', ja); // エラーは日本語化します
Vue.use(VeeValidate, { locale: 'ja' });

nuxt.config.js にvee-validate, axiosの設定を追加

const config = require('./.contentful.json')
const { createClient } = require('./plugins/contentful')
const client = createClient()

const modules = [
  ['@nuxtjs/pwa'],
  ['@nuxtjs/moment', ['ja']],
  // 以下の1行を追加
  ['@nuxtjs/axios']
]
if (process.env.NODE_ENV === 'production') {
  modules.push(['@nuxtjs/google-analytics', {
    id: process.env.GOOGLE_ANALYTICS_TRACKING_ID
  }])
}

module.exports = {
  env: {
    CTF_SPACE_ID: config.CTF_SPACE_ID || process.env.CTF_SPACE_ID,
    CTF_CDA_ACCESS_TOKEN: config.CTF_CDA_ACCESS_TOKEN || process.env.CTF_CDA_ACCESS_TOKEN,
    CTF_CMA_ACCESS_TOKEN: config.CTF_CMA_ACCESS_TOKEN || process.env.CTF_CMA_ACCESS_TOKEN,
    CTF_PERSON_ID: config.CTF_PERSON_ID || process.env.CTF_PERSON_ID,
    CTF_BLOG_POST_TYPE_ID: config.CTF_BLOG_POST_TYPE_ID || process.env.CTF_BLOG_POST_TYPE_ID,
    
    ALGOLIA_APPLICATION_ID: process.env.ALGOLIA_APPLICATION_ID,
    ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY,

    HTTP_SCHEMA: process.env.HTTP_SCHEMA,
    BASE_URL: process.env.BASE_URL,
    
    // 以下の1行を追加
    API_URL: process.env.API_URL,
  },
  /*
  ** Headers of the page
  */
 head: {
    htmlAttrs: {
      lang: 'ja',
    },
    title: 'なかむ🇭🇰エンジニアブログ',
    titleTemplate: '%s - なかむ🇭🇰エンジニアブログ',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1, minimum-scale=1' },
    ],
    link: [
      { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
    ]
  },
  plugins: [
    '~/plugins/buefy.js',
    '~/plugins/vue-scrollto.js',
    
    // 以下の1行を追加
    '~/plugins/vee-validate.js',
  ],
  css: [
    '~/assets/style/app.scss',
    '@fortawesome/fontawesome-free-webfonts',
    '@fortawesome/fontawesome-free-webfonts/css/fa-brands.css',
    '@fortawesome/fontawesome-free-webfonts/css/fa-regular.css',
    '@fortawesome/fontawesome-free-webfonts/css/fa-solid.css',
  ],
  /*
  ** Customize the progress bar color
  */
  loading: { color: '#3B8070' },
  /*
  ** Build configuration
  */
  build: {
    vendor: ['vee-validate'],
    /*
    ** Run ESLint on save
    */
    extend (config, { isDev, isClient }) {
      if (isDev && isClient) {
        config.module.rules.push({
          enforce: 'pre',
          test: /\.(js|vue)$/,
          loader: 'eslint-loader',
          exclude: /(node_modules)/
        })
      }
    }
  },
  generate: {
    routes () {
      return Promise.all([
        client.getEntries({
          'content_type': process.env.CTF_BLOG_POST_TYPE_ID
        }),
        client.getContentType(process.env.CTF_BLOG_POST_TYPE_ID)
      ])
      .then(([entries, postType]) => {
        return [
          '/posts',
          ...entries.items.map(entry => `/posts/${entry.fields.slug}`),
          ...postType.fields.find(field => field.id === 'tags').items.validations[0].in.map(tag => `/tags/${tag}`),
          ...['フロントエンド', 'バックエンド', 'プログラミング', 'その他'].map(category => `/categories/${category}`)
        ]
      })
    }
  },
  modules: modules,
  // axiosの設定を追加
  axios: {
    baseURL: process.env.API_URL,
  },
  manifest: {
    name: "なかむ🇭🇰エンジニアブログ",
    lang: 'ja',
    short_name: 'なかむ🇭🇰エンジニアブログ',
    theme_color: '#ffffff',
    background_color: '#ffffff'
  }
}

Netlifyにデプロイ&確認

githubにpushしてNetlifyにデプロイします。
まずは、環境変数に以下の2つを追加してください。

デプロイが完了したら問い合わせを試してみてください。

最後に

Nuxt.jsとNetlify Lambda Functionsの連携を実装しました。
これでDBへのデータ登録がいらないサービスはNetlifyで作れそうです。
(色々条件はありますが)

今後は、認証を試したり、データの保存先をどうするか検討&調査してみたいと思います。

参考記事

前のページ

次のページ

Profile

なかむ🇭🇰Webデベロッパー

なかむ🇭🇰Webデベロッパー

香港在住4年目になるWEBエンジニアのなかむです。 現在は、LaravelやRailsを利用したWEB開発を中心にエンジニアをしています。 顧客は全て日本の企業になります。リモート開発にて各企業様の支援を行なっております

プロフィール詳細はこちら

Latest Posts