Next.jsとWebNFCを使って勤怠メールを送るくんを作る(PWAアドカレネタのHistory)
はじめまして。がっちゃんです。
今回はPWAのアドベントカレンダー 10日目に登録したので、それ用のネタアプリを作っていく記録を残していきます。
自分が記事を書く予定のPWAカレンダー: https://qiita.com/advent-calendar/2020/pwa (まだまだ記事各人募集中っぽいのでよければ一緒に書きましょう)
目指すもの
WebNFCカードにPixel 3a(Android スマホ)をかざすことで、Gmail経由で勤怠メールを送信することができるWeb SPAアプリ
技術構成
- Next.js
- Chrome 86(Android版Stableの最新)
- Vercel(ホスティングサービス)
- GAS(Google Apps Script)
- 空のNFCカード(NTAG216)
実装イメージ
Chrome -> SPA(Next.js製)-> NFCカード読み取り -> HTTPリクエスト送信 -> (Webhook) -> GAS -> Gmail経由メール送信
初挑戦なもの
- Next.js
- WebNFC
- NFCカードは持ってて、WebNFCが使われた @takepepe さんのアプリケーションをざっくり読んだことはある
- Zenn Scrap
普段使ってるもの・できること
- Nuxt(Vue) / React
- npm/yarn等
- GAS
- Figma
- JavaScriptでSPAを不自由なく作ること
GitHub Repository
(初めてデフォルトブランチが main
のリポジトリを使った←
とりあえず公式を参考にCreate Next Appしとく
$ yarn create next-app web-nfc-kintai
Success! Created web-nfc-kintai at /home/yuki-gatchan0807/program/web-nfc-kintai/web-nfc-kintai
Inside that directory, you can run several commands:
yarn dev
Starts the development server.
yarn build
Builds the app for production.
yarn start
Runs the built app in production mode.
We suggest that you begin by typing:
cd web-nfc-kintai
yarn dev
Done in 11.46s.
いつも使ってるNuxtと大体コマンドは一緒やね🙆
とりあえずCreate Next Appで出来上がったディレクトリから↑で作ったリポジトリの階層にファイルたちを移動させて、 yarn dev
してやろうと思ったら他で動かしてるローカル環境とポートかぶったので↓を参考に package.json
を変更
わーい。Hello Next.js Worldできたー!
(ZennのScrap、GitHubみたいに画像をクリップボードから直接貼ることは出来なくてちょっと「あぁ…」って思ったけど、InputへのD&Dは対応してくれてて嬉しかったのでテンション的にはプラマイプラス🙆
Hello Next World出来たので今日はおしまい!
明日はどんな感じでUIを作っていくか、手書きでカンプみたいなのを作って、その後にNext.jsのページ単位をまず作っていく予定。
続きを1時間だけやっていき。
ページUIの手書きカンプの前にMVPとして記事にできるレベルのアプリを作るために必要な機能要件と技術要素を洗い出しする🤔
MVPの定義
Webアプリで、WebNFCをトリガーに自分(がっちゃん)のアカウントから、メール送信ができる
MVPの機能詳細
[App]
- NFCパスワード書き込みページ
- メール送信をする前に、まずNFCカードに対してパスワードを書き込んでおく(NFCカードを勤怠送るくん用に登録する)ためのページ
- NFC読み込みページでパスワード情報が登録されていない場合はこのページにリダイレクトされる
- NFCカードのIDとNFC内に事前に書き込まれたパスワードをHashにし、GASにリクエストする
- これによって誰でもWebHookにリクエストすることで勤怠メールを送ることが出来てしまう状態を防ぐ(WebHook URLは公開されるので、これによって認証の代わりとする)
- メール送信をする前に、まずNFCカードに対してパスワードを書き込んでおく(NFCカードを勤怠送るくん用に登録する)ためのページ
- NFC読み込みページ
- ページアクセス時にWebNFC(
NDEFReader
/NDEFWriter
)に対応しているかをチェックし、対応していない場合は対応ブラウザではない情報を表示する(合わせて、UAを元にデバイスのチェックも行う)- https://web.dev/nfc/ (参考にする)
- NFC読み込みを促すUIとパスワード書き込みページヘの導線を表示する
- NFC読み込みを実行された場合、下記の処理を行う
- NFCカードのID / 書き込まれているPWを読み込む
- ID / PWをハッシュ値化する
- Axios等で上記ハッシュ値をGAS Endpointに送信する
- レスポンスを元にUIを完了/失敗を表示する
- ページアクセス時にWebNFC(
[GAS]
- Webアプリ(Endpoint)として公開し、リクエストを受け付けて下記処理を行ってAppにレスポンスする
- リクエストで受け取ったハッシュ値をID/PWに戻し、スプレッドシート内にあるIDとPWの組み合わせと一致するかを確認する
- ID/PWが一致した場合、スプレッドシート内の文面テンプレートからテンプレートと文言を取得する
- 取得した文言を元にGmailAppを通してメール送信を行う
[スプレッドシート]
- 下記シート(簡易データベース)を用意する
- カードのID/PWを登録するシート
- 下記メールにかかわる3つをまとめて登録するシート
- 文面のテンプレート
- 選択されるタイミング
- メールの送信先アドレス
- テンプレートに埋め込む可変テキストを管理するシート
プロダクト価値(Value)のMVPからの拡張可能性・拡張方向性
①自分(がっちゃん)のアカウントから
- Googleアカウントを持っているユーザーであれば誰でも
- WebアプリからシームレスにGoogleアカウントのOAuth認証を実施し、GASの実行権限を付与->Google認証したGoogleアカウントとしてメール送信を行う
- https://tonari-it.com/gas-gmail-sendemail (参考にできるかも?)
②メール送信
- メール送信宛先をWebアプリから設定できる
- CC: 対応?(これは複数Toを設定できるようにするだけでいいかも)
- メールの文面のテンプレを登録し、直接利用できる
- イメージとしてはNFC読み込み画面で先にテンプレを選択した上で読み取りを実施する感じ
- もしくは、デフォルトは勤怠開始で、登録された業務開始時間を過ぎている場合はNFC読み取り時にポップアップで理由を追記できるようにするとか
- 業務開始・終了時間の登録機能が必要
- メールの文面のテンプレ内のテキスト(例えば遅刻理由など)を設定・入力できる
思ったよりMVPでやることのまとめに時間がかかってしまった…w
雑な手書きUIカンプ(UIイメージ)
とりあえず自分がUIをイメージできればOKなので読みにくい(というか読めないレベルの文字の汚さな)のはご愛嬌
ということで予定してた部分は終わったので早速明日からはNext.jsでアプリケーション作っていこう。
あと完全に忘れてたけどGAS部分の実装があるからClaspも使うし、そのリポジトリも用意しとかんとやな🤔
昨日はタスクと体調不良でちょっと死んでたので引き続き…!
まずはページを作るのと、Nuxtでいう mounted
的なタイミング( componentDidMount
でいいんかな?)で、UAベースでのデバイス(ブラウザ)の確認と NDEFReader
/ NDEFWriter
が使えるかのチェックをしよう
あと、PCのChromeと実機をつないでリアルタイムにChrome DevToolsを見えるようにする検証の準備をせねば。
- PCとAndroid端末をデータ転送可のUSBでつなぐ
- AndroidをデベロッパーモードONにする( https://developer.android.com/studio/debug/dev-options?hl=ja )
- chrome://inspect/#devices にアクセスする
- Android側でChromeを起動する!
参照:
あとそうだ。localhostのポートをポートフォワーディングしておかんとアカンのやった。
↓の「Port forwarding...」のボタンからダイアログを出して、Next.jsのDevServerを起動してるポート情報を登録して、ダイアログの下の方にある「Enable port forwarding」のチェックボックスにチェックあげると、ちょっとした後にAndroid Chrome側で localhost:XXXX
でアクセスできるようになる🙆
これで実機テスト準備OK。
Next.jsのページ作成のやり方とか調べていくぞー
この辺を読みながら…👀
でもまあ、なんとなくNuxtと似てるなぁ。
違うといえば、チュートリアルの一つのチャプターにちっさいクイズがついてるとこかな←
Next.jsのエラーはこんな感じで出るのね👀
そっか。Reactベースだからexport default
に設定したFunctionの返り値でJSX的にHTMLテキストを返すだけで良いのね。
ほとんどNext.js側でラップされてるのか🤔
あと、今までSSRが入らないページ間遷移をCSR、CSRって呼んでたけど、Client-side navigationって呼び方が正しいのか。理解理解🙆
pages/index.js
に設定された↓のあたりを読みながらふむふむしてる。
Nuxtの head()
はNext.jsパッケージ内の Head
コンポーネントが対応してるのね。
react-helmet的な雰囲気を感じてる
あとこれが最近噂のCSS Moduleか…!(↓のお二人の話をTLで観測して流し読みはしていた)
import Head from "next/head";
import styles from "../styles/Home.module.css";
export default function Home() {
return (
<div className={styles.container}>
<Head>
<title>Create Next App</title>
<link rel="icon" href="/favicon.ico" />
</Head>
/** ~~ */
</div>
);
}
これNextとTailwindCSSって組み合わせれるんかな?
多分行けるとは思うけど、、、
最近Reactアプリの方でStyled Componentいじってていいなぁと思いつつ、Nuxtアプリ2件の方ではTailwindCSSを使ってて後者の方が個人的には好みだったりするのでこっちにしたいなぁと思ってたり🤔
先にページのつなぎこみとかやってしまって、後で調べよ👀
なんかクソダサい感じのH1が出来たけど良しとしようw
ちょっとだけ↓のScrapを思い出したw
お。Style関連のところにTailwindCSS対応してるよ!って書いてた👀
あとはデフォルトで style-jsx
なんてものに対応してるのね。
emotionとかstlyed-componentは知ってたけどこれは初めて聞いた…🤔
さて。チュートリアルのところ一通り流したのでそろそろDocsの方から目当ての機能( componentDidMount
的なライフサイクルとか、Hooks的なStateとかの扱い)を探していこう…👀
お!と思って、getStaticProps
の名前で async
ファンクションを pages
内の関数で定義しておいたらNext.jsは自動的にプリロードしてレンダリングしておくよ!ってのを読んでたんやけど、今回のアプリケーションでは使わんか…🤔
もしかして: nextjs lifecycle 古い (?)
おー。これが噂に聞いていたSSRとSSGのハイブリットできるよってお話か
そしてじゅんじゅんのScrapを発見するなど
なんか結構頭が混乱してきたんやけど、Next.jsってCSRがベースのフレームワークではない…?
そこまでは言ってない気はするものの、SSR or SSGの文脈の記事や情報が多くてCSRする場合の書き方はどうなるのかイマイチつかめていない🤔
ふつーにReactアプリのようにReact Hooksベースの書き方で良いの…か…?
ちょっと頭こんがらがってきたのと体調まだ万全ではないので一時休戦…😪
明日早起きできたらもうちょっと調べよ。
おやすみなさい、いい夢を。
続きをやっていき🙆
とりあえずReact Hooksベースで NDEFReader
の存在を確認する処理を追加してみる🙆
<main className={styles.main}>
<h1 className={styles.title}>
カードにパスコードを<Link href="/register">登録する</Link>
</h1>
<NFCUsableFlag></NFCUsableFlag>
</main>
function NFCUsableFlag() {
const [nfcFlag, setNFCFlag] = useState("False");
useEffect(() => {
if (process.browser) {
if ("NDEFReader" in window) {
setNFCFlag("True");
}
}
});
return <div>NFC Usable: {nfcFlag}</div>;
}
完全に勘違いしててんけど、WebNFCって最新版のChromeでもデフォルト機能としてONになってないのね…
Experimental Web Platform features
ってのをEnableにする必要があるみたいで、なんかTrueにならん…SSR側で判定されてるんか…?って悩んでた…w
CSS Moduleで複数クラスどうやって設定するんかなって思ったらテンプレートリテラルで設定するのね🙆
基本的なDOM構造とStyleを当ては終わったからNFCでカードの情報読み込む処理を追加するマン
パスコード登録画面でNFCカードIDを取得するためにHooksにNFC読み込みを開始するロジック追加した🙆
const [nfcId, setNFCId] = useState("");
useEffect(() => {
const reader = new NDEFReader();
reader
.scan()
.then(() => {
reader.onerror = (event) => {
console.log(event);
alert("何らかの原因で読み込みに失敗しました");
};
reader.onreading = (event) => {
setNFCId(event.serialNumber);
};
})
.catch((error) => {
alert("NFCカードの読み込み準備に失敗しました");
});
});
(一旦NFC関連のエラー全部握りつぶして適当なアラート出してるのはご愛嬌)
パスコード入力完了後にしばらくしてからNFC書き込みを開始する仕様にしようと思ってるんやけどこの辺の問題でうまいこと受付開始できない🤔
試行錯誤中の関数
const inputPassCode = (passcode) => {
setPasscode(passcode);
console.log(passcode);
if (timer !== 0) {
console.log(timer);
clearTimeout(timer);
}
const timerId = setTimeout(() => {
console.log("write");
setMainMessage(MAIN_MESSAGE.WRITE_STAND_BY);
console.log(mainMessage);
}, 5000);
setTimer(timerId);
};
mainMessage
が変わっていない…🤔
setTimeout Gotchas 😲
あった!これだ!
useRef
を使うのね!
useRef
使わなくてよかったし何なら問題の原因、渡したPropsを描画に使ってなかったのが原因というしょうもないミスやった…
- NFCカードのシリアルナンバー読み込み
- カード用パスコードの入力
- 入力されたパスコードのカードへの書き込み
までできるようになった!🙆
書き込みチェックのために、書き込み時にオープンした NDEFWriter
のセッションと一番最初にカードID確認用のためにオープンしてるセッションを閉じたいんやけどどうすればいいんやろ…🤔
MDNさんもまだDraftって感じやから情報が少ない…🤔
NFCカードの書き込みチェック処理追加は出来た🙆
次はTOPページで読み込み実行して、Axios実行やね
ちょこちょこと実装進めてて、悩んだのがAPIへのデータPOST / GETはAxiosを使わんくてよいのか?なんやけど、公式Docsやとfetch API使ってるしそっち使うか
本来のAPIはClasp使ってGASに立てる予定やけど、まずローカルでチェックするためにモックAPI立てたくて、既存の /api/hello
をちょちょっといじって↓な感じで簡単に立てれた🙆
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
export default (req, res) => {
if (req.method === "POST") {
res.statusCode = 200;
res.json({ name: "John Doe" });
} else if (req.method === "GET") {
res.statusCode = 200;
res.json({ name: "Hello John." });
}
};
リクエスト部分はカードにパスコード登録する部分を流用して↓な感じに
// 初回NFCカードID確認
useEffect(() => {
const reader = new NDEFReader();
setMainMessage(MAIN_MESSAGE.READ_WAIT);
reader
.scan()
.then(() => {
reader.onerror = (event) => {
console.log(event);
alert("何らかの原因で読み込みに失敗しました");
};
reader.onreading = async (event) => {
console.log(event.serialNumber);
setMainMessage(MAIN_MESSAGE.SENDING);
const res = await fetch("/api/hello");
console.log(await res.json());
};
})
}, [])
APIリクエスト部分はちょっとコード量多くなりそうやし別の関数に切り出そ
ってかそうか。NFCの reader.scan
のハンドラーたちもこれ、関数にくくりだして共通化できるな🤔
↑のハンドラーたちをどこのディレクトリにまとめてあげるのがデファクトスタンダードなのかなぁってのを調べてたら良記事を見つけた👀
Next.js初心者なのでこういうのとてもありがてぇ
そうだ、忘れてた!↓の本の実践の章にNext.jsに関する記述があった…!
この中からそれっぽいところ探しに行こう👀
…やけど、時間切れなので残りは退勤後…!
とりあえずリファクタをして、 共通のHandlerは一旦 lib/nfcCommonHandler.js
ってファイル作ってそこにまとめた🙆
Read系のページとタイミングによって異なる処理のハンドラはUI返すコンポーネントの下にプライベートな関数として定義して対応。
多分これ useEffect
とかCustom Hooksとかもっとちゃんと使いこなせたらもっときれいに書けるんやろけどいまいちそこを理解してない状態で使うのはドツボにはまるからスキップ…!
Next.js側はAPIにリクエストする処理を書けばOKで、今度はそのリクエストを受け取るAPIをGAS上に作るところを進めねば
@yKicchan くんの↓の記事とリポジトリを参考にnpm使って特定の関数だけentorypointに書き出すようにしたGASをつくるんだ
そうだった… doPost
で送るのはNGだった…