それでは、PIN方式の行商アプリを実装していきます!
まず、プロジェクトのディレクトリ構造とDocker環境から構築します。
version: '3.8'
services:
app:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- MONGODB_URI=mongodb://mongo:27017/gyosho
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=mailto:your-email@example.com
depends_on:
- mongo
volumes:
- ./src:/app/src
- ./views:/app/views
- ./public:/app/public
restart: unless-stopped
mongo:
image: mongo:7
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
restart: unless-stopped
volumes:
mongo-data:
次に、画面のルートファイルを作成します:
export default async function vendorRoutes(fastify) {
// 行商登録画面
fastify.get('/register', async (request, reply) => {
return reply.view('vendor/register.pug', {
title: '行商登録 - 行商アプリ'
});
});
// 行商ホーム画面
fastify.get('/home', async (request, reply) => {
return reply.view('vendor/home.pug', {
title: '行商ホーム - 行商アプリ'
});
});
// PIN一覧表示画面
fastify.get('/pins', async (request, reply) => {
return reply.view('vendor/pins.pug', {
title: 'PIN一覧 - 行商アプリ'
});
});
}
次に、Pugテンプレートを作成します:
doctype html
html(lang='ja')
head
meta(charset='UTF-8')
meta(name='viewport' content='width=device-width, initial-scale=1.0')
meta(name='theme-color' content='#2196f3')
meta(name='description' content='行商と顧客をつなぐシンプルな通知アプリ')
title= title
link(rel='manifest' href='/manifest.json')
link(rel='icon' type='image/png' href='/public/icon-192.png')
link(rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css')
link(href='https://fonts.googleapis.com/icon?family=Material+Icons' rel='stylesheet')
link(rel='stylesheet' href='/public/css/style.css')
body
.container
.section.center-align
h1.title 🐟 行商アプリ
p.flow-text.grey-text.text-darken-2 行商と顧客をつなぐ、かんたん通知アプリ
.row
.col.s12.m6
.card.hoverable
.card-content.center-align
i.material-icons.large.blue-text.text-darken-2 store
span.card-title 行商の方
p.grey-text お客様に通知を送ります
.card-action.center-align
a.btn.btn-large.blue.darken-2.waves-effect.waves-light(href='/vendor/home')
| はじめる
i.material-icons.right arrow_forward
.col.s12.m6
.card.hoverable
.card-content.center-align
i.material-icons.large.green-text.text-darken-2 person
span.card-title お客様
p.grey-text 留守の時だけお知らせします
.card-action.center-align
a.btn.btn-large.green.darken-2.waves-effect.waves-light(href='/customer/home')
| はじめる
i.material-icons.right arrow_forward
.section.center-align
h5.grey-text.text-darken-1 使い方
.row
.col.s12.m4
i.material-icons.medium.grey-text.text-darken-1 app_registration
p.flow-text 1. 登録
p 行商の方が登録してPINを発行
.col.s12.m4
i.material-icons.medium.grey-text.text-darken-1 vpn_key
p.flow-text 2. PIN入力
p お客様がPINを入力して登録
.col.s12.m4
i.material-icons.medium.grey-text.text-darken-1 notifications_active
p.flow-text 3. 通知
p ボタン一つで通知が届く
script(src='https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js')
script(src='/public/js/app.js')
次に、Service Workerとフロントエンドのコードを作成します。
public/sw.js
const CACHE_NAME = 'gyosho-app-v1';
const urlsToCache = [
'/',
'/vendor',
'/customer',
'/public/css/style.css',
'/public/js/app.js',
'/manifest.json'
];
// インストール時
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => {
console.log('キャッシュを開きました');
return cache.addAll(urlsToCache);
})
);
self.skipWaiting();
});
// アクティベート時
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
console.log('古いキャッシュを削除:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim();
});
// フェッチ時
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// キャッシュがあれば返す
if (response) {
return response;
}
// なければネットワークから取得
return fetch(event.request);
})
);
});
// プッシュ通知受信
self.addEventListener('push', (event) => {
console.log('プッシュ通知を受信しました');
let data = {
title: '🐟 行商アプリ',
message: '通知が届きました'
};
if (event.data) {
try {
data = event.data.json();
} catch (e) {
data.message = event.data.text();
}
}
const options = {
body: data.message,
icon: '/public/icon-192.png',
badge: '/public/icon-192.png',
vibrate: [300, 100, 300, 100, 300],
requireInteraction: true,
tag: 'gyosho-notification',
data: {
timestamp: data.timestamp || new Date().toISOString()
},
actions: [
{
action: 'open',
title: 'アプリを開く'
},
{
action: 'close',
title: '閉じる'
}
]
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// 通知クリック時
self.addEventListener('notificationclick', (event) => {
console.log('通知がクリックされました');
event.notification.close();
if (event.action === 'open' || !event.action) {
event.waitUntil(
clients.openWindow('/')
);
}
});
js/customer.js
// 顧客モード用JavaScript
let isRegistered = false;
let customerInfo = null;
// 顧客情報の読み込み
async function loadCustomerInfo() {
const stored = localStorage.getItem('customerRegistered');
if (stored === 'true') {
try {
const response = await fetch(`/api/customer/${deviceId}`);
if (response.ok) {
customerInfo = await response.json();
isRegistered = true;
showCustomerDashboard(customerInfo);
// プッシュ通知の再購読
const registration = await navigator.serviceWorker.ready;
const subscription = await subscribeToPush(registration);
if (subscription) {
await updatePushSubscription(subscription);
}
} else {
showRegistrationForm();
}
} catch (error) {
console.error('顧客情報の取得エラー:', error);
showRegistrationForm();
}
} else {
showRegistrationForm();
}
}
// 登録フォーム表示
function showRegistrationForm() {
document.getElementById('registration-form').style.display = 'block';
document.getElementById('customer-dashboard').style.display = 'none';
}
// ダッシュボード表示
function showCustomerDashboard(data) {
document.getElementById('registration-form').style.display = 'none';
document.getElementById('customer-dashboard').style.display = 'block';
document.getElementById('vendor-name-display').textContent = data.vendorName;
// ステータス表示を更新
updateStatusDisplay(data.lastStatus);
}
// ステータス表示を更新
function updateStatusDisplay(lastStatus) {
const statusCard = document.getElementById('status-card');
const today = new Date();
today.setHours(0, 0, 0, 0);
if (lastStatus && lastStatus.date) {
const statusDate = new Date(lastStatus.date);
statusDate.setHours(0, 0, 0, 0);
if (statusDate.getTime() === today.getTime() && !lastStatus.isHome) {
statusCard.className = 'card red lighten-4';
statusCard.innerHTML = `
<div class="card-content center-align">
<i class="material-icons large red-text">home</i>
<h5 class="red-text">本日は留守</h5>
<p>行商さんに留守を伝えました</p>
</div>
`;
} else {
statusCard.className = 'card green lighten-4';
statusCard.innerHTML = `
<div class="card-content center-align">
<i class="material-icons large green-text">check_circle</i>
<h5 class="green-text">在宅</h5>
<p>通知を受け取ります</p>
</div>
`;
}
}
}
// PIN入力(数字ボタン)
function inputPin(number) {
const pinInputs = document.querySelectorAll('.pin-input');
for (let i = 0; i < pinInputs.length; i++) {
if (!pinInputs[i].value) {
pinInputs[i].value = number;
pinInputs[i].classList.add('filled');
// 4桁入力されたら自動的に次へ
if (i === 3) {
setTimeout(() => {
document.getElementById('register-btn').focus();
}, 100);
}
break;
}
}
}
// PIN削除
function deletePin() {
const pinInputs = document.querySelectorAll('.pin-input');
for (let i = pinInputs.length - 1; i >= 0; i--) {
if (pinInputs[i].value) {
pinInputs[i].value = '';
pinInputs[i].classList.remove('filled');
break;
}
}
}
// PINクリア
function clearPin() {
const pinInputs = document.querySelectorAll('.pin-input');
pinInputs.forEach(input => {
input.value = '';
input.classList.remove('filled');
});
}
// 顧客登録
async function registerCustomer() {
const pinInputs = document.querySelectorAll('.pin-input');
const pin = Array.from(pinInputs).map(input => input.value).join('');
if (pin.length !== 4) {
showToast('4桁のPINを入力してください', 3000);
return;
}
const name = document.getElementById('customer-name').value.trim();
const address = document.getElementById('customer-address').value.trim();
showLoading();
try {
// Service Workerとプッシュ通知の準備
const registration = await navigator.serviceWorker.ready;
// 通知許可をリクエスト
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
showToast('通知を許可してください', 4000);
hideLoading();
return;
}
const subscription = await subscribeToPush(registration);
// 顧客登録API呼び出し
const response = await fetch('/api/customer/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pin,
deviceId,
pushSubscription: subscription,
name,
address
})
});
const data = await response.json();
if (data.success) {
localStorage.setItem('customerRegistered', 'true');
showToast(`${data.vendorName}に登録しました!`, 4000);
// ダッシュボード表示
setTimeout(() => {
loadCustomerInfo();
}, 1000);
} else {
showToast(data.error || '登録に失敗しました', 4000);
}
} catch (error) {
console.error('登録エラー:', error);
showToast('登録に失敗しました', 4000);
} finally {
hideLoading();
}
}
// プッシュ購読情報を更新
async function updatePushSubscription(subscription) {
try {
await fetch('/api/customer/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
deviceId,
pushSubscription: subscription
})
});
} catch (error) {
console.error('プッシュ購読更新エラー:', error);
}
}
// 留守通知送信
async function notifyAway() {
if (!deviceId) {
showToast('エラーが発生しました', 3000);
return;
}
const btn = document.getElementById('away-btn');
btn.disabled = true;
showLoading();
try {
const response = await fetch('/api/customer/notify-away', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ deviceId })
});
const data = await response.json();
if (data.success) {
showToast('行商さんに留守を伝えました', 4000);
// ステータス表示を更新
updateStatusDisplay({
date: new Date(),
isHome: false
});
// 顧客情報を再読み込み
setTimeout(() => {
loadCustomerInfo();
}, 1000);
} else {
showToast(data.error || '送信に失敗しました', 4000);
}
} catch (error) {
console.error('留守通知エラー:', error);
showToast('送信に失敗しました', 4000);
} finally {
hideLoading();
btn.disabled = false;
}
}
// 在宅に戻す
async function notifyHome() {
if (!deviceId) {
showToast('エラーが発生しました', 3000);
return;
}
const btn = document.getElementById('home-btn');
btn.disabled = true;
showLoading();
try {
const response = await fetch('/api/customer/notify-home', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ deviceId })
});
const data = await response.json();
if (data.success) {
showToast('在宅に戻しました', 3000);
// ステータス表示を更新
updateStatusDisplay({
date: new Date(),
isHome: true
});
// 顧客情報を再読み込み
setTimeout(() => {
loadCustomerInfo();
}, 1000);
} else {
showToast(data.error || '更新に失敗しました', 4000);
}
} catch (error) {
console.error('在宅通知エラー:', error);
showToast('更新に失敗しました', 4000);
} finally {
hideLoading();
btn.disabled = false;
}
}
// リセット(デバッグ用)
function resetCustomer() {
if (confirm('登録情報をリセットしますか?\n(この操作は取り消せません)')) {
localStorage.removeItem('customerRegistered');
location.reload();
}
}
// 初期化
document.addEventListener('DOMContentLoaded', () => {
loadCustomerInfo();
});
js/vender.js
// 行商モード用JavaScript
let vendorId = null;
let isRegistered = false;
// 行商情報の読み込み
async function loadVendorInfo() {
vendorId = localStorage.getItem('vendorId');
if (vendorId) {
try {
const response = await fetch(`/api/vendor/${vendorId}`);
if (response.ok) {
const data = await response.json();
isRegistered = true;
showVendorDashboard(data);
// プッシュ通知の再購読
const registration = await navigator.serviceWorker.ready;
const subscription = await subscribeToPush(registration);
if (subscription) {
await updatePushSubscription(subscription);
}
} else {
showRegistrationForm();
}
} catch (error) {
console.error('行商情報の取得エラー:', error);
showRegistrationForm();
}
} else {
showRegistrationForm();
}
}
// 登録フォーム表示
function showRegistrationForm() {
document.getElementById('registration-form').style.display = 'block';
document.getElementById('vendor-dashboard').style.display = 'none';
}
// ダッシュボード表示
function showVendorDashboard(data) {
document.getElementById('registration-form').style.display = 'none';
document.getElementById('vendor-dashboard').style.display = 'block';
document.getElementById('vendor-name').textContent = data.name;
document.getElementById('customer-count').textContent = data.customerCount;
document.getElementById('unused-pin-count').textContent = data.unusedPinCount;
// 留守の顧客を取得
loadAwayCustomers();
}
// 行商登録
async function registerVendor() {
const name = document.getElementById('shop-name').value.trim();
const phoneNumber = document.getElementById('phone-number').value.trim();
if (!name) {
showToast('店名を入力してください', 3000);
return;
}
showLoading();
try {
// Service Workerとプッシュ通知の準備
const registration = await navigator.serviceWorker.ready;
const subscription = await subscribeToPush(registration);
// 行商登録API呼び出し
const response = await fetch('/api/vendor/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name,
phoneNumber,
pushSubscription: subscription
})
});
const data = await response.json();
if (data.success) {
vendorId = data.vendorId;
localStorage.setItem('vendorId', vendorId);
// PIN一覧を表示
showPinList(data.pins, data.name);
} else {
showToast(data.error || '登録に失敗しました', 4000);
}
} catch (error) {
console.error('登録エラー:', error);
showToast('登録に失敗しました', 4000);
} finally {
hideLoading();
}
}
// PIN一覧表示
function showPinList(pins, shopName) {
const modal = document.getElementById('pin-modal');
const pinList = document.getElementById('pin-list');
pinList.innerHTML = '<h5>顧客に配布するPIN番号</h5>';
pinList.innerHTML += '<p class="grey-text">お客様にこれらの番号を紙に書いて渡してください</p>';
const grid = document.createElement('div');
grid.className = 'row';
pins.forEach((pin, index) => {
const col = document.createElement('div');
col.className = 'col s6 m4 l3';
col.innerHTML = `
<div class="card blue darken-1">
<div class="card-content white-text center-align">
<span class="card-title" style="font-size: 2rem; font-weight: bold;">${pin}</span>
</div>
</div>
`;
grid.appendChild(col);
});
pinList.appendChild(grid);
const instance = M.Modal.getInstance(modal);
instance.open();
}
// モーダルを閉じてダッシュボード表示
function closePinModal() {
const modal = document.getElementById('pin-modal');
const instance = M.Modal.getInstance(modal);
instance.close();
// ダッシュボードを再読み込み
loadVendorInfo();
}
// プッシュ購読情報を更新
async function updatePushSubscription(subscription) {
try {
await fetch('/api/vendor/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
vendorId,
pushSubscription: subscription
})
});
} catch (error) {
console.error('プッシュ購読更新エラー:', error);
}
}
// 通知送信
async function sendNotification() {
if (!vendorId) {
showToast('行商情報が見つかりません', 3000);
return;
}
const btn = document.getElementById('notify-btn');
btn.disabled = true;
showLoading();
try {
const response = await fetch('/api/vendor/notify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ vendorId })
});
const data = await response.json();
if (data.success) {
let message = `${data.sent}人のお客様に通知しました`;
if (data.skipped > 0) {
message += `\n(${data.skipped}人は留守のためスキップ)`;
}
showToast(message, 5000);
// 留守の顧客を更新
loadAwayCustomers();
} else {
showToast(data.error || '送信に失敗しました', 4000);
}
} catch (error) {
console.error('通知送信エラー:', error);
showToast('送信に失敗しました', 4000);
} finally {
hideLoading();
btn.disabled = false;
}
}
// 留守の顧客一覧を取得
async function loadAwayCustomers() {
if (!vendorId) return;
try {
const response = await fetch(`/api/vendor/${vendorId}/away-customers`);
const data = await response.json();
const container = document.getElementById('away-customers');
if (data.awayCustomers && data.awayCustomers.length > 0) {
container.innerHTML = '<h6 class="red-text">📭 本日留守のお客様</h6>';
const list = document.createElement('ul');
list.className = 'collection';
data.awayCustomers.forEach(customer => {
const li = document.createElement('li');
li.className = 'collection-item';
const time = new Date(customer.notifiedAt).toLocaleTimeString('ja-JP', {
hour: '2-digit',
minute: '2-digit'
});
li.innerHTML = `
<strong>${customer.name}</strong><br>
<span class="grey-text">${customer.address}</span><br>
<span class="grey-text text-darken-1">通知時刻: ${time}</span>
`;
list.appendChild(li);
});
container.appendChild(list);
} else {
container.innerHTML = '<p class="grey-text">本日留守のお客様はいません</p>';
}
} catch (error) {
console.error('留守顧客取得エラー:', error);
}
}
// リセット(デバッグ用)
function resetVendor() {
if (confirm('行商情報をリセットしますか?\n(この操作は取り消せません)')) {
localStorage.removeItem('vendorId');
location.reload();
}
}
// 初期化
document.addEventListener('DOMContentLoaded', () => {
M.Modal.init(document.querySelectorAll('.modal'));
loadVendorInfo();
});