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を利用します。
こちらを参考に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"リンクを押下することで、問い合わせ画面が開くようにします。
コンポーネントとして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つを追加してください。
- API_URL: APIのベースURL (私のブログだと、https://blog.nakamu.life/.netlify/functions)
- SLACK_URL: SLACKのWebhook URL
デプロイが完了したら問い合わせを試してみてください。
最後に
Nuxt.jsとNetlify Lambda Functionsの連携を実装しました。
これでDBへのデータ登録がいらないサービスはNetlifyで作れそうです。
(色々条件はありますが)
今後は、認証を試したり、データの保存先をどうするか検討&調査してみたいと思います。