🚧

[LINE][Firebase][Cloud Storage]道路通報アプリ

に公開

本記事の分類

  • 学習ノート

機能

  • LINE公式アカウントのリッチメニューから投稿ページを開く
  • 道路の不具合情報(内容、写真、位置)を投稿する
  • 道路の不具合情報をFirestore、Cloud Storage(国内region)に保存する
  • 投稿者のラインアカウントへ投稿(受付)内容を送信する

想定シーン

  • 道路の穴ボコ等をみつけた人が土木事務所にラインから通報する

仕様

  • システム仕様は下図のとおり

  • スマホ画面の仕様は下図のとおり

  • 管理画面の仕様は下図のとおり



Firebaseの設定

https://firebase.google.com/?hl=ja

  • FirebaseのTOPページを開く

  • コンソールへ移動をクリック

  • ”新しいFirebaseプロジェクトを作成”をクリック

  • 任意のプロジェクト名(ここでは”sample-app"とした。)を入力し、”続行”をクリック。

  • ”続行”をクリック

  • ”続行”をクリック

  • ”Default Account for Firebase”をクリック
  • ”プロジェクトを作成”をクリック

  • ”続行”をクリック

  • ”Sparkプラン”をクリック

  • 従量制の”プランを選択”をクリック

  • ”Cloud 請求先アカウントを作成する”をクリック

  • ”確認”をクリック

  • ”連絡先情報”を設定
  • ”連絡先情報”を設定
  • ”購入を確定”をクリック

![](https://storage.googleapis.com/zenn-user-upload/1caedd0. Set up automatic builds and deploys with GitHub?
→ No (n) を選択ed801-20260105.png)

  • JPYの予算額を入力(ここでは500円とした。)
  • ”続行”をクリック

  • ”Cloud請求先アカウントをリンク”をクリック

- "完了”をクリック



Google Cloudの設定

  • Google Cloud のページを開き、右上の”コンソール”をクリック

  • Google Cloud 横のアイコンをクリック
  • 新しいプロジェクトをクリック

  • "作成”をクリック

  • ロゴ「Google Cloud」の右のボタンをクリック
  • 先程作成したプロジェクト名を選択

  • ロゴ「Google Cloud」の左の三本線をクリック

  • ”課金”をクリック

  • ”リンクされた請求先のアカウントに移動”をクリック

  • 概要が表示されることを確認する

  • ”APIとサービス”→”ライブラリ”をクリック

  • "cloud functions api"と入力し検索

  • ”Cloud Functions API”をクリック

  • ”有効にする”をクリック

同様に以下APIを有効化する

  • Google Cloud Firestore API
  • Cloud Storage API
  • Cloud Build API

Firestore (データベース) の設定

  • 作成したProjectの名称をクリック(ここではsample-app)

  • ”構築”→”Firestore Database”をクリック

  • "データベースの作成”をクリック

  • ”次へ”をクリック

- ”ロケーション”を ”asia-northeast1(TOKYO)”に設定

  • ”次へ”をクリック

  • ”作成”をクリック


Cloud Storage (ストレージ) の設定

  • ”構築”→”Storage"をクリック

  • ”使ってみる”をクリック

  • ”ロケーション”→”リージョン”→”ASIA-EAST1"をクリック

  • ”作成”をクリック


プロジェクトフォルダの作成

  • 任意の場所にプロジェクトフォルダを作成
mkdir tutorial-for-report-app-about-road-firebase
cd tutorial-for-report-app-about-road-firebase

※フォルダ名は任意に命名して問題ない



Firebaseのインストールと設定

sudo apt-get update
sudo apt install npm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
sudo npm install -g firebase-tools
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
nvm install --lts
firebase login

※termanl上でnode --versionを実行しversion情報が出ない場合は、node.jsをインストールしてください。

firebase login

  • 認証のため表示されたURLをctrl + クリック
firebase init

firebase init を実⾏、以下のように回答


. Which Firebase features do you want to set up?
→ Functions と Hosting を選択(スペースキーで選択、エンターキーで決定)



. Please select an option:
→ Use an existing project を選択
. Select a default Firebase project:
→ 先ほど作成した road-report-app を選択


. What language would you like to use to write Cloud Functions?
→ JavaScript を選択


. Do you want to use ESLint...?
→ Yes (y) を選択(コードの品質を保つため)


. Do you want to install dependencies with npm now?
→ Yes (y) を選択


. What do you want to use as your public directory?
→ public のままでエンターキーを押す


. Configure as a single-page app?
→ No (n) を選択


. Set up automatic builds and deploys with GitHub?
→ No (n) を選択


  • 完了すると、プロジェクトフォルダ内にfunctionsフォルダやpublicフォルダ、firebase.jsonなどが自動的に作成される


フォルダとファイルの追加

  • editorは何でも良いです
  • 作成ファイルは、functions/index.html、publicフォルダ配下のファイル

functions/index.js

  • 以下のとおり
const functions = require("firebase-functions");
const admin = require("firebase-admin");
const axios = require("axios");
const nodemailer = require("nodemailer");
const cors = require("cors")({ origin: true });

admin.initializeApp();
const db = admin.firestore();
const bucket = admin.storage().bucket();

// 環境変数から設定を取得
const LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN;
const LINE_LOGIN_CHANNEL_ID = process.env.LINE_LOGIN_CHANNEL_ID;
const LIFF_ID = process.env.LIFF_ID;

const LINE_MESSAGING_API_URL = 'https://api.line.me/v2/bot/message/push';
const LINE_PROFILE_API_URL = 'https://api.line.me/v2/profile';
const LINE_VERIFY_TOKEN_URL = 'https://api.line.me/oauth2/v2.1/verify';

exports.config = functions.region('asia-northeast1').https.onRequest((req, res) => {
    cors(req, res, () => {
        res.json({
            LIFF_ID: LIFF_ID
        });
    });
});

exports.report = functions.region('asia-northeast1').https.onRequest((req, res) => {
    cors(req, res, async () => {
        if (req.method !== 'POST') {
            return res.status(405).send('Method Not Allowed');
        }

        try {
            const rawData = req.body;
            console.log('Request data received:', JSON.stringify(rawData));

            // 1. データの検証とサニタイズ
            const validatedData = validateAndSanitizeData(rawData);

            // 2. ユーザー認証
            let userId = null;
            if (validatedData.accessToken) {
                userId = await getUserIdFromAccessToken(validatedData.accessToken);
                validatedData.userId = userId;
            } else {
                throw new Error('アクセストークンが見つかりません。認証が必要です。');
            }

            // 3. データベース保存 (Firestore & Storage)
            const saveResult = await saveToFirestoreAndStorage(validatedData);

            // 4. LINE通知
            let lineResult = null;
            if (userId && LINE_CHANNEL_ACCESS_TOKEN) {
                lineResult = await sendLineMessage(userId, validatedData, saveResult);
            }

            // 5. メール通知
            try {
                // 宛先リストを取得
                const recipientsSnapshot = await db.collection('mail_recipients').get();
                const recipients = [];
                recipientsSnapshot.forEach(doc => {
                    const rData = doc.data();
                    if (rData.email && rData.email.includes('@')) {
                        recipients.push(rData.email);
                    }
                });

                if (recipients.length > 0) {
                    const gmailConfig = functions.config().gmail;
                    const gmailEmail = gmailConfig ? gmailConfig.email : null;
                    const gmailPassword = gmailConfig ? gmailConfig.password : null;

                    if (gmailEmail && gmailPassword) {
                        const transporter = nodemailer.createTransport({
                            service: 'gmail',
                            auth: {
                                user: gmailEmail,
                                pass: gmailPassword
                            }
                        });

                        const subject = `【道路通報】新規通報(種別:${validatedData.type})`;
                        let mailBody = "新しい道路通報がありましたので、お知らせします。\n\n";
                        mailBody += "----------------------------------------\n";
                        mailBody += "■ 通報内容\n";
                        mailBody += "----------------------------------------\n";
                        mailBody += `・受付日時: ${new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' })}\n`;
                        mailBody += `・通報種別: ${validatedData.type}\n`;
                        mailBody += `・詳細: ${validatedData.details || '記載なし'}\n\n`;
                        mailBody += `・場所の確認(Googleマップ):\n${saveResult.googleMapLink}\n\n`;

                        if (saveResult.photoUrl) {
                            mailBody += `・写真の確認:\n${saveResult.photoUrl}\n\n`;
                        } else {
                            mailBody += "・写真: なし\n\n";
                        }
                        mailBody += "----------------------------------------\n";
                        // Cloud FunctionsのURLではなく、Firebase HostingのURLを使用する
                        // プロジェクトIDからHostingのURLを構築(または環境変数で管理しても良いが、今回は簡易的に構築)
                        const projectId = process.env.GCLOUD_PROJECT || process.env.FIREBASE_CONFIG?.projectId;
                        const hostingUrl = `https://${projectId}.web.app`;
                        mailBody += `管理画面: ${hostingUrl}/admin.html\n`;
                        mailBody += `配信設定: ${hostingUrl}/admin_email.html\n`;

                        const mailOptions = {
                            from: `"Road Report App" <${gmailEmail}>`,
                            to: recipients.join(','),
                            subject: subject,
                            text: mailBody
                        };

                        await transporter.sendMail(mailOptions);
                        console.log('Email sent to:', recipients);
                    } else {
                        console.log('Gmail config not found. Skipping email.');
                    }
                } else {
                    console.log('No recipients found. Skipping email.');
                }
            } catch (mailError) {
                console.error('Error sending email:', mailError);
                // メール送信失敗しても、通報自体は成功とするためエラーは投げない
            }

            res.status(200).json({
                status: 'success',
                message: '通報を受け付けました。ご協力ありがとうございます。',
                timestamp: new Date().toISOString(),
                id: saveResult.id,
                lineNotified: !!lineResult,
                imageUploaded: !!saveResult.photoUrl
            });

        } catch (error) {
            console.error('Error processing request:', error);
            res.status(500).json({
                status: 'error',
                message: 'データの処理に失敗しました: ' + error.message
            });
        }
    });
});

function validateAndSanitizeData(rawData) {
    const latitude = parseFloat(rawData.latitude);
    const longitude = parseFloat(rawData.longitude);

    if (isNaN(latitude) || isNaN(longitude) || !rawData.type) {
        throw new Error('必須フィールド(緯度、経度、種別)が無効または不足しています。');
    }

    // photoDataの検証
    if (rawData.photoData) {
        // サイズチェック (簡易)
        if (rawData.photoData.length > 7 * 1024 * 1024) { // Base64で約7MB (元ファイル5MB程度)
            throw new Error('画像サイズが大きすぎます。');
        }
        if (!rawData.photoData.startsWith('data:image/')) {
            throw new Error('無効な画像データ形式です。');
        }
    }

    let photoMimeType = null;
    if (rawData.photoData) {
        photoMimeType = rawData.photoData.substring(5, rawData.photoData.indexOf(';'));
    }

    return {
        latitude,
        longitude,
        type: sanitizeText(rawData.type),
        details: rawData.details ? sanitizeText(rawData.details) : '',
        photoData: rawData.photoData || null,
        photoMimeType,
        accessToken: rawData.accessToken || null
    };
}

function sanitizeText(text) {
    if (typeof text !== 'string') return text;
    return text.replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}

async function getUserIdFromAccessToken(accessToken) {
    try {
        // トークン検証
        const verifyResponse = await axios.get(`${LINE_VERIFY_TOKEN_URL}?access_token=${accessToken}`);
        if (verifyResponse.data.client_id !== LINE_LOGIN_CHANNEL_ID) {
            throw new Error('チャネルIDが一致しません。');
        }

        // プロフィール取得
        const profileResponse = await axios.get(LINE_PROFILE_API_URL, {
            headers: { 'Authorization': 'Bearer ' + accessToken }
        });
        return profileResponse.data.userId;
    } catch (error) {
        console.error('Authentication error:', error.response ? error.response.data : error.message);
        throw new Error('ユーザー認証に失敗しました。');
    }
}

async function saveToFirestoreAndStorage(data) {
    try {
        let photoUrl = '';
        let storagePath = '';

        // 写真保存
        if (data.photoData && data.photoMimeType) {
            const base64Data = data.photoData.split(',')[1];
            const buffer = Buffer.from(base64Data, 'base64');
            const filename = `reports/${Date.now()}_${Math.random().toString(36).substring(7)}.jpg`;
            const file = bucket.file(filename);

            await file.save(buffer, {
                metadata: { contentType: data.photoMimeType },
                public: true // 公開設定 (必要に応じて変更)
            });

            photoUrl = file.publicUrl();
            storagePath = filename;
        }

        const googleMapLink = `https://www.google.com/maps/search/?api=1&query=${data.latitude},${data.longitude}`;

        // Firestore保存
        const docRef = await db.collection('reports').add({
            timestamp: admin.firestore.FieldValue.serverTimestamp(),
            status: '未処理', // デフォルトステータス
            latitude: data.latitude,
            longitude: data.longitude,
            googleMapLink,
            type: data.type,
            details: data.details,
            photoUrl,
            storagePath,
            userId: data.userId
        });

        return {
            id: docRef.id,
            photoUrl,
            googleMapLink
        };
    } catch (error) {
        console.error('Database/Storage error:', error);
        throw new Error('データの保存に失敗しました。');
    }
}

async function sendLineMessage(userId, reportData, saveResult) {
    try {
        const messages = [];

        // Flex Message
        messages.push(createFlexMessage(reportData, saveResult.photoUrl));

        // Location Message
        messages.push({
            type: 'location',
            title: '通報場所',
            address: `緯度: ${reportData.latitude}, 経度: ${reportData.longitude}`,
            latitude: reportData.latitude,
            longitude: reportData.longitude
        });

        // Image Message
        if (saveResult.photoUrl) {
            messages.push({
                type: 'image',
                originalContentUrl: saveResult.photoUrl,
                previewImageUrl: saveResult.photoUrl
            });
        }

        // Text Message
        messages.push({
            type: 'text',
            text: createLineTextMessage(reportData, saveResult.googleMapLink, saveResult.photoUrl)
        });

        await axios.post(LINE_MESSAGING_API_URL, {
            to: userId,
            messages: messages
        }, {
            headers: {
                'Content-Type': 'application/json',
                'Authorization': 'Bearer ' + LINE_CHANNEL_ACCESS_TOKEN
            }
        });

        return true;
    } catch (error) {
        console.error('LINE Messaging API error:', error.response ? error.response.data : error.message);
        // LINE送信失敗はメイン処理のエラーとしない
        return false;
    }
}

function createFlexMessage(data, photoUrl) {
    return {
        type: 'flex',
        altText: '道路異状通報を受け付けました',
        contents: {
            type: 'bubble',
            header: {
                type: 'box',
                layout: 'vertical',
                contents: [
                    { type: 'text', text: '🚧 道路異状通報', weight: 'bold', color: '#ffffff', size: 'lg' },
                    { type: 'text', text: '受付完了', color: '#ffffff', size: 'sm' }
                ],
                backgroundColor: '#3498db',
                paddingAll: 'lg'
            },
            body: {
                type: 'box',
                layout: 'vertical',
                contents: [
                    {
                        type: 'box',
                        layout: 'vertical',
                        contents: [
                            { type: 'text', text: '受付日時', color: '#666666', size: 'sm' },
                            { type: 'text', text: new Date().toLocaleString('ja-JP'), weight: 'bold', size: 'md', margin: 'xs' }
                        ],
                        margin: 'md'
                    },
                    {
                        type: 'box',
                        layout: 'vertical',
                        contents: [
                            { type: 'text', text: '通報種別', color: '#666666', size: 'sm' },
                            { type: 'text', text: data.type, weight: 'bold', size: 'md', margin: 'xs', color: '#e74c3c' }
                        ],
                        margin: 'md'
                    },
                    {
                        type: 'box',
                        layout: 'vertical',
                        contents: [
                            { type: 'text', text: '詳細情報', color: '#666666', size: 'sm' },
                            { type: 'text', text: data.details || '記載なし', size: 'md', margin: 'xs', wrap: true }
                        ],
                        margin: 'md'
                    }
                ]
            },
            footer: {
                type: 'box',
                layout: 'vertical',
                contents: [
                    {
                        type: 'button',
                        style: 'primary',
                        action: {
                            type: 'uri',
                            label: '🗺️ 地図で確認',
                            uri: `https://www.google.com/maps?q=${data.latitude},${data.longitude}`
                        },
                        color: '#27ae60'
                    }
                ],
                margin: 'md'
            }
        }
    };
}

function createLineTextMessage(data, mapLink, photoLink) {
    const timestamp = new Date().toLocaleString('ja-JP', {
        year: 'numeric', month: '2-digit', day: '2-digit',
        hour: '2-digit', minute: '2-digit'
    });
    let message = `📋 通報詳細\n\n`;
    message += `🔸 種別: ${data.type}\n`;
    message += `🔸 詳細: ${data.details || '記載なし'}\n`;
    message += `🔸 受付日時: ${timestamp}\n\n`;
    if (mapLink) {
        message += `📍 場所の確認:\n${mapLink}\n\n`;
    }
    if (photoLink) {
        message += `📷 写真の確認:\n${photoLink}\n\n`;
    }
    message += `📍 通報を受け付けました。\n`;
    message += `ご協力ありがとうございました。`;
    return message;
}


admin_email.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>メール配信先管理 - 道路不具合通報</title>
    <link rel="stylesheet" href="style.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
    <style>
        .container {
            max-width: 800px;
            margin: 20px auto;
            padding: 20px;
            background-color: white;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
        }

        h2 {
            border-bottom: 2px solid #3498db;
            padding-bottom: 10px;
            margin-bottom: 20px;
        }

        .form-row {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
            align-items: flex-end;
        }

        .form-group {
            flex: 1;
            margin-bottom: 0;
        }

        .form-group label {
            display: block;
            margin-bottom: 5px;
            font-weight: bold;
        }

        .form-group input {
            width: 100%;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }

        .btn-add {
            background-color: #2ecc71;
            color: white;
            border: none;
            padding: 10px 20px;
            border-radius: 4px;
            cursor: pointer;
            font-weight: bold;
        }

        .btn-add:hover {
            background-color: #27ae60;
        }

        table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 20px;
        }

        th,
        td {
            padding: 12px;
            border-bottom: 1px solid #eee;
            text-align: left;
        }

        th {
            background-color: #f8f9fa;
            font-weight: bold;
        }

        .btn-delete {
            background-color: #e74c3c;
            color: white;
            border: none;
            padding: 5px 10px;
            border-radius: 4px;
            cursor: pointer;
        }

        .btn-delete:hover {
            background-color: #c0392b;
        }

        .back-link {
            display: inline-block;
            margin-bottom: 20px;
            color: #3498db;
            text-decoration: none;
        }

        .back-link:hover {
            text-decoration: underline;
        }
    </style>

    <!-- Firebase SDKs -->
    <script defer src="/__/firebase/10.7.1/firebase-app-compat.js"></script>
    <script defer src="/__/firebase/10.7.1/firebase-auth-compat.js"></script>
    <script defer src="/__/firebase/10.7.1/firebase-firestore-compat.js"></script>
    <script defer src="/__/firebase/init.js"></script>

    <script defer src="auth.js?v=7"></script>
    <script defer src="admin_email.js?v=7"></script>
</head>

<body>
    <div class="container">
        <a href="admin.html" class="back-link"><i class="fas fa-arrow-left"></i> 通報一覧に戻る</a>

        <h2>メール配信先管理</h2>
        <p>ここで登録されたメールアドレスに、通報があった際に通知メールが送信されます。</p>

        <div class="form-row">
            <div class="form-group">
                <label for="name">氏名</label>
                <input type="text" id="name" placeholder="例: 山田 太郎">
            </div>
            <div class="form-group">
                <label for="email">メールアドレス</label>
                <input type="email" id="email" placeholder="例: user@example.com">
            </div>
            <button class="btn-add" onclick="addRecipient()">追加</button>
        </div>

        <table>
            <thead>
                <tr>
                    <th>氏名</th>
                    <th>メールアドレス</th>
                    <th style="width: 80px;">操作</th>
                </tr>
            </thead>
            <tbody id="recipient-list">
                <tr>
                    <td colspan="3" style="text-align: center;">読み込み中...</td>
                </tr>
            </tbody>
        </table>
    </div>
</body>

</html>

admin_email.js

// 認証後に呼び出される関数
window.startAdminApp = function (user) {
    console.log('Starting email admin app for:', user.email);
    loadRecipients();
};

async function loadRecipients() {
    // タイムアウト設定(10秒)
    const timeoutPromise = new Promise((_, reject) =>
        setTimeout(() => reject(new Error('読み込みがタイムアウトしました。')), 10000)
    );

    try {
        const db = firebase.firestore();
        // Firestoreの取得とタイムアウトを競走させる
        const snapshot = await Promise.race([
            db.collection('mail_recipients').orderBy('name').get(),
            timeoutPromise
        ]);

        const tbody = document.getElementById('recipient-list');
        tbody.innerHTML = '';

        if (snapshot.empty) {
            tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">登録された宛先はありません</td></tr>';
            return;
        }

        snapshot.forEach(doc => {
            const data = doc.data();
            const tr = document.createElement('tr');
            tr.innerHTML = `
                <td>${escapeHtml(data.name)}</td>
                <td>${escapeHtml(data.email)}</td>
                <td>
                    <button class="btn-delete" onclick="deleteRecipient('${doc.id}', '${escapeHtml(data.name)}')">削除</button>
                </td>
            `;
            tbody.appendChild(tr);
        });

    } catch (error) {
        console.error("Error loading recipients:", error);
        alert('読み込みに失敗しました: ' + error.message);
    }
}

async function addRecipient() {
    const nameInput = document.getElementById('name');
    const emailInput = document.getElementById('email');
    const name = nameInput.value.trim();
    const email = emailInput.value.trim();

    if (!name || !email) {
        alert('氏名とメールアドレスを入力してください');
        return;
    }

    if (!email.includes('@')) {
        alert('正しいメールアドレスを入力してください');
        return;
    }

    try {
        const db = firebase.firestore();
        await db.collection('mail_recipients').add({
            name: name,
            email: email,
            createdAt: firebase.firestore.FieldValue.serverTimestamp()
        });

        nameInput.value = '';
        emailInput.value = '';
        loadRecipients(); // リロード
        alert('追加しました');

    } catch (error) {
        console.error("Error adding recipient:", error);
        alert('追加に失敗しました: ' + error.message);
    }
}

async function deleteRecipient(id, name) {
    if (!confirm(`${name} さんを削除してもよろしいですか?`)) {
        return;
    }

    try {
        const db = firebase.firestore();
        await db.collection('mail_recipients').doc(id).delete();
        loadRecipients(); // リロード
    } catch (error) {
        console.error("Error deleting recipient:", error);
        alert('削除に失敗しました: ' + error.message);
    }
}

function escapeHtml(str) {
    if (!str) return '';
    return str.replace(/[&<>"']/g, function (m) {
        return {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;'
        }[m];
    });
}

// グローバル関数として公開
window.addRecipient = addRecipient;
window.deleteRecipient = deleteRecipient;


admin.html

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>道路不具合通報 管理画面</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
        integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
    <link rel="stylesheet" href="style.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
    <style>
        /* 管理画面用スタイル */
        body {
            display: flex;
            flex-direction: column;
            height: 100vh;
            overflow: hidden;
            /* 全体スクロール禁止 */
        }

        .admin-header {
            padding: 10px 20px;
            background-color: #3498db;
            color: white;
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-shrink: 0;
        }

        .admin-header h2 {
            margin: 0;
            font-size: 20px;
            color: white;
            border: none;
            padding: 0;
        }

        .refresh-btn {
            background: none;
            border: 1px solid white;
            color: white;
            padding: 5px 10px;
            border-radius: 4px;
            cursor: pointer;
        }

        .refresh-btn:hover {
            background-color: rgba(255, 255, 255, 0.2);
        }

        /* 地図エリア */
        .map-container {
            height: 40%;
            /* 上部40% */
            width: 100%;
            position: relative;
            flex-shrink: 0;
            border-bottom: 2px solid #ddd;
        }

        #admin-map {
            width: 100%;
            height: 100%;
        }

        /* テーブルエリア */
        .table-container {
            flex: 1;
            /* 残りの高さ */
            overflow: auto;
            /* スクロール */
            padding: 10px;
            background-color: #f8f9fa;
        }

        table {
            width: 100%;
            border-collapse: collapse;
            background-color: white;
            font-size: 14px;
        }

        th,
        td {
            padding: 10px;
            border: 1px solid #ddd;
            text-align: left;
            vertical-align: middle;
        }

        th {
            background-color: #f1f1f1;
            position: sticky;
            top: 0;
            z-index: 10;
            font-weight: bold;
            color: #2c3e50;
        }

        tr:hover {
            background-color: #f5f5f5;
        }

        tr.active {
            background-color: #e3f2fd;
        }

        /* ステータスバッジ */
        .status-badge {
            display: inline-block;
            padding: 4px 8px;
            border-radius: 12px;
            font-size: 11px;
            font-weight: bold;
            color: white;
            text-align: center;
            min-width: 60px;
        }

        .status-unprocessed {
            background-color: #e74c3c;
        }

        .status-processed {
            background-color: #2ecc71;
        }

        .status-select {
            padding: 2px;
            font-size: 12px;
            border-radius: 4px;
            border: 1px solid #ccc;
        }

        /* リンク */
        a.map-link {
            color: #3498db;
            text-decoration: none;
        }

        a.map-link:hover {
            text-decoration: underline;
        }

        /* サムネイル */
        .thumb-img {
            width: 50px;
            height: 50px;
            object-fit: cover;
            border-radius: 4px;
            cursor: pointer;
            border: 1px solid #ddd;
        }

        /* ID列 */
        .id-cell {
            font-family: monospace;
            font-size: 11px;
            color: #7f8c8d;
            max-width: 100px;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap;
        }

        /* レスポンシブ */
        @media (max-width: 768px) {
            .map-container {
                height: 30%;
            }

            th,
            td {
                padding: 6px;
                font-size: 12px;
            }

            .id-cell {
                display: none;
            }

            /* スマホではID非表示 */
        }
    </style>

    <!-- Firebase SDKs -->
    <script defer src="/__/firebase/10.7.1/firebase-app-compat.js"></script>
    <script defer src="/__/firebase/10.7.1/firebase-auth-compat.js"></script>
    <script defer src="/__/firebase/10.7.1/firebase-firestore-compat.js"></script>
    <script defer src="/__/firebase/init.js"></script>

    <!-- Leaflet -->
    <script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
        integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>

    <!-- Admin Script -->
    <script defer src="auth.js?v=7"></script>
    <script defer src="admin.js?v=7"></script>
</head>

<body>
    <div class="admin-header">
        <h2>通報一覧</h2>
        <div>
            <a href="admin_email.html" style="color: white; margin-right: 15px; text-decoration: none;">
                <i class="fas fa-envelope"></i> メール配信設定
            </a>
            <button class="refresh-btn" onclick="location.reload()">
                <i class="fas fa-sync-alt"></i> 更新
            </button>
        </div>
    </div>

    <div class="map-container">
        <div id="admin-map"></div>
    </div>

    <div class="table-container">
        <table id="report-table">
            <thead>
                <tr>
                    <th style="width: 100px;">ステータス</th>
                    <th style="width: 140px;">受付日時</th>
                    <th style="width: 100px;">通報種別</th>
                    <th>詳細</th>
                    <th style="width: 150px;">場所 (緯度, 経度)</th>
                    <th style="width: 60px;">地図</th>
                    <th style="width: 70px;">写真</th>
                    <th class="id-cell" style="width: 80px;">ID</th>
                </tr>
            </thead>
            <tbody id="report-list">
                <tr>
                    <td colspan="8" style="text-align: center; color: #777;">読み込み中...</td>
                </tr>
            </tbody>
        </table>
    </div>
</body>

</html>

admin.js

// 認証後に呼び出される関数
window.startAdminApp = function (user) {
    console.log('Starting admin app for:', user.email);
    initMap();
    loadReports();
};



let map;
let markers = [];
let reports = [];

// 地図の初期化
function initMap() {
    if (map) return;
    // 日本全体を表示(データ読み込み後に調整)
    map = L.map('admin-map').setView([36.2048, 138.2529], 5);

    L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', {
        attribution: "地理院タイル(GSI)",
        maxZoom: 18
    }).addTo(map);
}

// データの読み込み
async function loadReports() {
    // タイムアウト設定(10秒)
    const timeoutPromise = new Promise((_, reject) =>
        setTimeout(() => reject(new Error('読み込みがタイムアウトしました。ネットワーク接続を確認してください。')), 10000)
    );

    try {
        const db = firebase.firestore();
        // Firestoreの取得とタイムアウトを競走させる
        const snapshot = await Promise.race([
            db.collection('reports').limit(100).get(),
            timeoutPromise
        ]);

        const tbody = document.getElementById('report-list');
        tbody.innerHTML = ''; // クリア

        if (snapshot.empty) {
            tbody.innerHTML = '<tr><td colspan="8" style="text-align: center;">データがありません</td></tr>';
            return;
        }

        const bounds = L.latLngBounds();

        snapshot.forEach(doc => {
            const data = doc.data();
            const id = doc.id;
            reports.push({ id, ...data });

            // テーブル行作成
            const tr = createTableRow(id, data);
            tbody.appendChild(tr);

            // マーカー作成
            if (data.latitude && data.longitude) {
                const marker = L.marker([data.latitude, data.longitude])
                    .addTo(map)
                    .bindPopup(createPopupContent(data));

                marker.reportId = id;
                markers.push(marker);
                bounds.extend([data.latitude, data.longitude]);

                // マーカークリックイベント
                marker.on('click', () => {
                    highlightTableRow(id);
                });
            }
        });

        // 全マーカーが入るようにズーム調整
        if (markers.length > 0) {
            map.fitBounds(bounds, { padding: [50, 50] });
        }

    } catch (error) {
        console.error("Error getting documents: ", error);
        document.getElementById('report-list').innerHTML =
            `<tr><td colspan="8" style="color: red;">エラーが発生しました: ${error.message}</td></tr>`;
    }
}

function createTableRow(id, data) {
    const tr = document.createElement('tr');
    tr.dataset.id = id;

    const date = data.timestamp ? new Date(data.timestamp.toDate()).toLocaleString('ja-JP') : '日時不明';
    const type = data.type || '不明';
    const details = data.details || '';
    const lat = data.latitude ? data.latitude.toFixed(6) : '-';
    const lng = data.longitude ? data.longitude.toFixed(6) : '-';
    const googleMapLink = data.googleMapLink || '#';
    const photoUrl = data.photoUrl || '';
    const status = data.status || '未処理';

    // ステータス選択肢
    const statusOptions = `
            <select class="status-select" onchange="updateStatus('${id}', this.value)">
                <option value="未処理" ${status === '未処理' ? 'selected' : ''}>未処理</option>
                <option value="処理済" ${status === '処理済' ? 'selected' : ''}>処理済</option>
            </select>
        `;

    // 写真リンク
    let photoHtml = '-';
    if (photoUrl) {
        photoHtml = `<a href="${photoUrl}" target="_blank"><img src="${photoUrl}" class="thumb-img" loading="lazy" alt="写真"></a>`;
    }

    // Google Mapリンク
    let mapLinkHtml = '-';
    if (data.googleMapLink) {
        mapLinkHtml = `<a href="${data.googleMapLink}" target="_blank" class="map-link"><i class="fas fa-map-marker-alt"></i> Map</a>`;
    }

    tr.innerHTML = `
            <td>${statusOptions}</td>
            <td>${date}</td>
            <td>${type}</td>
            <td>${details}</td>
            <td>${lat}, ${lng}</td>
            <td>${mapLinkHtml}</td>
            <td>${photoHtml}</td>
            <td class="id-cell" title="${id}">${id}</td>
        `;

    tr.addEventListener('click', (e) => {
        // インタラクティブ要素(セレクト、リンク)のクリックは無視
        if (['SELECT', 'A', 'IMG', 'I'].includes(e.target.tagName)) return;

        focusOnMap(id, data.latitude, data.longitude);
        highlightTableRow(id);
    });

    return tr;
}

function createPopupContent(data) {
    const date = data.timestamp ? new Date(data.timestamp.toDate()).toLocaleString('ja-JP') : '日時不明';
    const status = data.status || '未処理';
    let content = `<b>${data.type}</b> <span style="font-size:12px; color:${status === '処理済' ? 'green' : 'red'}">(${status})</span><br>${date}<br>${data.details || ''}`;
    if (data.photoUrl) {
        content += `<br><img src="${data.photoUrl}" style="width:100%; max-width:200px; margin-top:5px; border-radius:4px;">`;
    }
    if (data.googleMapLink) {
        content += `<br><a href="${data.googleMapLink}" target="_blank">Google Mapで見る</a>`;
    }
    return content;
}

// グローバル関数として定義(HTMLから呼ぶため)
window.updateStatus = async function (id, newStatus) {
    try {
        const db = firebase.firestore();
        await db.collection('reports').doc(id).update({
            status: newStatus
        });
        // 簡易的にトースト表示(本来はライブラリなど使うと良い)
        // alert('ステータスを更新しました'); 
        // リロードせず、行の色を変えるなどの処理だけでも良いが、今回はシンプルに

        // 行のスタイル更新(未処理/処理済の色分けなどあれば)
        // 今回はセレクトボックスの値が変わるだけなので特になし
        console.log('Status updated to ' + newStatus);
    } catch (error) {
        console.error("Error updating status: ", error);
        alert('更新に失敗しました: ' + error.message);
    }
};

function highlightTableRow(id) {
    // 全てのactiveクラスを削除
    document.querySelectorAll('tr').forEach(item => {
        item.classList.remove('active');
    });

    // 指定されたIDの行をactiveにする
    const target = document.querySelector(`tr[data-id="${id}"]`);
    if (target) {
        target.classList.add('active');
        target.scrollIntoView({ behavior: 'smooth', block: 'center' });
    }
}

function focusOnMap(id, lat, lng) {
    if (lat && lng) {
        map.setView([lat, lng], 16);
        const marker = markers.find(m => m.reportId === id);
        if (marker) {
            marker.openPopup();
        }
    }
}

// 実行



auth.js

// 認証ロジックとログインUIの管理

// ログインオーバーレイを即座に作成・表示(Firebase初期化前でも)
createLoginOverlay();
showLoginOverlay();

document.addEventListener('DOMContentLoaded', function () {
    // Firebaseの初期化を待つ
    const checkFirebase = setInterval(() => {
        if (typeof firebase !== 'undefined' && firebase.apps && firebase.apps.length > 0) {
            clearInterval(checkFirebase);
            initAuth();
        }
    }, 100);
});

let isAppStarted = false;

function initAuth() {
    const auth = firebase.auth();

    // 認証状態の監視
    auth.onAuthStateChanged(user => {
        if (user) {
            // ログイン済み
            console.log('Logged in as:', user.email);
            hideLoginOverlay();
            showLogoutButton(user.email);

            // メインアプリの開始(各ページで定義されている関数を呼ぶ)
            // 二重起動防止
            if (window.startAdminApp && !isAppStarted) {
                isAppStarted = true;
                window.startAdminApp(user);
            } else if (!window.startAdminApp) {
                console.error('window.startAdminApp is not defined! admin.js may not be loaded correctly.');
                alert('管理画面のプログラム読み込みに失敗しました。ページを再読み込みしてください。');
            }
        } else {
            // 未ログイン
            console.log('Not logged in');
            isAppStarted = false;
            showLoginOverlay();
            hideLogoutButton();
        }
    });
}

function createLoginOverlay() {
    // すでに存在すれば何もしない
    if (document.getElementById('login-overlay')) return;

    const overlay = document.createElement('div');
    overlay.id = 'login-overlay';
    overlay.style.cssText = `
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.8);
        display: flex;
        justify-content: center;
        align-items: center;
        z-index: 9999;
    `;

    const loginBox = document.createElement('div');
    loginBox.style.cssText = `
        background-color: white;
        padding: 30px;
        border-radius: 8px;
        box-shadow: 0 4px 6px rgba(0,0,0,0.1);
        width: 100%;
        max-width: 400px;
        text-align: center;
    `;

    loginBox.innerHTML = `
        <h2 style="margin-bottom: 20px; color: #333;">管理者ログイン</h2>
        <div style="margin-bottom: 15px; text-align: left;">
            <label style="display: block; margin-bottom: 5px; font-weight: bold;">メールアドレス</label>
            <input type="email" id="login-email" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;">
        </div>
        <div style="margin-bottom: 20px; text-align: left;">
            <label style="display: block; margin-bottom: 5px; font-weight: bold;">パスワード</label>
            <input type="password" id="login-password" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box;">
        </div>
        <button id="login-btn" style="width: 100%; padding: 12px; background-color: #3498db; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer;">ログイン</button>
        <p id="login-error" style="color: #e74c3c; margin-top: 15px; display: none;"></p>
    `;

    overlay.appendChild(loginBox);
    document.body.appendChild(overlay);

    // イベントリスナー
    const loginBtn = document.getElementById('login-btn');
    const emailInput = document.getElementById('login-email');
    const passwordInput = document.getElementById('login-password');
    const errorMsg = document.getElementById('login-error');

    async function handleLogin() {
        const email = emailInput.value;
        const password = passwordInput.value;

        loginBtn.disabled = true;
        loginBtn.textContent = 'ログイン中...';
        errorMsg.style.display = 'none';

        try {
            await firebase.auth().signInWithEmailAndPassword(email, password);
            // 成功すれば onAuthStateChanged が発火してオーバーレイが消える
        } catch (error) {
            console.error('Login error:', error);
            errorMsg.textContent = 'ログインに失敗しました: ' + error.message;
            errorMsg.style.display = 'block';
            loginBtn.disabled = false;
            loginBtn.textContent = 'ログイン';
        }
    }

    loginBtn.addEventListener('click', handleLogin);

    // Enterキーでもログイン
    passwordInput.addEventListener('keypress', (e) => {
        if (e.key === 'Enter') handleLogin();
    });
}

function showLoginOverlay() {
    const overlay = document.getElementById('login-overlay');
    if (overlay) overlay.style.display = 'flex';
}

function hideLoginOverlay() {
    const overlay = document.getElementById('login-overlay');
    if (overlay) overlay.style.display = 'none';
}

function showLogoutButton(email) {
    // ヘッダー内のログアウトボタンコンテナを探す(なければ作る)
    let logoutContainer = document.getElementById('logout-container');
    if (!logoutContainer) {
        // admin-headerの中に挿入を試みる
        const header = document.querySelector('.admin-header');
        if (header) {
            logoutContainer = document.createElement('div');
            logoutContainer.id = 'logout-container';
            logoutContainer.style.cssText = 'display: flex; align-items: center; margin-left: auto;';

            // 既存の要素(更新ボタンなど)の前に挿入するか、末尾に追加するか
            // ここでは末尾に追加(flexなので右端に行くはず)
            header.appendChild(logoutContainer);
        }
    }

    if (logoutContainer) {
        logoutContainer.innerHTML = `
            <span style="margin-right: 10px; font-size: 12px;">${email}</span>
            <button onclick="firebase.auth().signOut()" style="background: none; border: 1px solid white; color: white; padding: 5px 10px; border-radius: 4px; cursor: pointer;">
                <i class="fas fa-sign-out-alt"></i> ログアウト
            </button>
        `;
    }
}

function hideLogoutButton() {
    const logoutContainer = document.getElementById('logout-container');
    if (logoutContainer) {
        logoutContainer.innerHTML = '';
    }
}


index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
    <title>道路の不具合通報フォーム</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
    <link rel="stylesheet" href="style.css" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
    <!-- LIFF SDK追加 -->
    <script defer charset="utf-8" src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
    <script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
    <script defer src="script.js"></script>
</head>
<body>
    <!-- ローディング画面 -->
    <div id="loader" class="loader-overlay hidden">
        <div class="loader"></div>
        <p id="loader-text" class="loader-text">処理中です...</p>
    </div>

    <!-- メインコンテンツ -->
    <div class="container">
        <form id="report-form" novalidate>
            <h2>道路の不具合を通報</h2>

            <!-- LINE連携状態表示 -->
            <div id="line-status" class="line-status hidden">
                <div class="line-status-content">
                    <i class="fab fa-line line-icon"></i>
                    <span id="line-status-text">LINE連携を確認中...</span>
                </div>
            </div>

            <div class="form-group">
                <label class="form-label">1. 通報内容</label>
                <div class="radio-group">
                    <div class="radio-item">
                        <input type="radio" id="type1" name="type" value="雑草" required>
                        <label for="type1">雑草</label>
                    </div>
                    <div class="radio-item">
                        <input type="radio" id="type2" name="type" value="倒木">
                        <label for="type2">倒木</label>
                    </div>
                    <div class="radio-item">
                        <input type="radio" id="type3" name="type" value="路面の穴ぼこ・段差">
                        <label for="type3">路面の穴ぼこ・段差</label>
                    </div>
                    <div class="radio-item">
                        <input type="radio" id="type4" name="type" value="落下物・汚れ">
                        <label for="type4">落下物・汚れ</label>
                    </div>
                    <div class="radio-item">
                        <input type="radio" id="type-other" name="type" value="その他">
                        <label for="type-other">その他</label>
                    </div>
                </div>
            </div>

            <div class="form-group">
                <label for="details" class="form-label">
                    詳細(100文字まで)
                    <span class="label-note" id="details-required-note"></span>
                </label>
                <textarea id="details" name="details" rows="4" placeholder="例:直径20cm程度の穴ぼこ" maxlength="100"></textarea>
            </div>

            <div class="form-group">
                <label for="photo">3. 現場の写真 (任意)</label>
                <div class="photo-controls">
                    <label for="photo" class="button-like-input">
                        <i class="fas fa-file-image"></i> ファイルを選択
                    </label>
                    <input type="file" id="photo" name="photo" accept="image/*" style="display: none;">
                </div>
                <img id="image-preview" src="#" alt="写真プレビュー"/>
            </div>

            <div class="form-group">
                <label>4. 現場の場所</label>
                <div id="map-wrapper">
                    <div id="map"></div>
                    <i id="center-pin" class="fas fa-map-marker-alt"></i>
                </div>
                <div id="coords-display">地図を動かして位置を合わせてください</div>
                <input type="hidden" id="latitude" name="latitude" required>
                <input type="hidden" id="longitude" name="longitude" required>
                <!-- LINEアクセストークン用の隠しフィールド -->
                <input type="hidden" id="accessToken" name="accessToken">
                <input type="hidden" id="userId" name="userId">
            </div>

            <button type="submit" id="btn-submit" disabled>不具合の種類を選択してください</button>
        </form>
    </div>
</body>
</html>

Firebase のデプロイ


npx firebase deploy

Authentication の導入

  • ”構築”→”Authentication”をクリック

  • ”始める”をクリック

  • 任意にログイン方法を選択(必要に応じメールアドレス等を登録)

Hosting

  • ”構築”→”Hosting”をクリック

  • "始める"をクリック

  • 表示された手順どおりにコマンドを実行する。

うまくいかないときは明示的にプロジェクトを指定してみる。

  • $ firebase projects:list

  • $ firebase use 作成したprojectIDに置き換え

  • .firebasercファイルの値がプロジェクトIDと一致しているか確認する。

LINE公式アカウント等作成

https://entry.line.biz/start/jp/

LINEプロバイダー作成

https://developers.line.biz/ja/

  • LINE developeアクセス(上のリンクをクリック)

  • 右上の”コンソール”をクリック

  • ”作成”をクリック

  • ”+新規チャンネル作成”をクリック

  • ”LINEログイン”をクリック
  • 必要事項を入力
  • アプリタイプは”ウェブアプリ”
  • ”作成”をクリック
  • ”MessagingAPI"をクリック
  • ”LINE公式アカウントを作成する”をクリック
  • 必要事項を入力し”完了”をクリック
  • ”LINE Official Managerへ”をクリック
  • ”同意”等をクリックして進める
  • ”ホーム画面へ移動”をクリック

  • 右上の”設定”をクリック

  • ”MessagingAPI”をクリック
  • ”MessagingAPIを利用する”をクリック
  • 作成したプロバイダーを選択し”同意する”をクリック
  • 何も入力せず”OK”をクリック
  • ”OK"をクリック

LINEリッチメニュー作成

  • 作成した”MessagingAPI"をクリック(筆者は”作業班dev"と命名)

  • ”LINE Official Account Manager"をクリック

  • ”ホーム”タブをクリック
  • ”リッチメニュー”をクリック

  • 作成をクリック

  • ”メニュー”の上部エリアをクリック

  • 左下の添付レートを選択
  • ”選択”をクリック

  • ”タイプ”は”リンク”を選択


  • firebaseでhostingしたwebsiteのURLをコピー

  • コピーしたURLをペースト
  • ”保存”をクリック

  • LINE Developers にアクセス
  • LINE login の LIFF タブをクリック

  • ”LIFFアプリ名”を任意に設定
  • ”サイズ”はFull
  • ”エンドポイントURL”欄に貼り付け
  • "Scope"は"profile"を選択
  • ”友達追加オプション”は”On(normal)"を選択
  • ”追加”をクリック

  • 作成したLINEログインチャンネルの”LIFF”タブをクリック
  • LIFF ID をコピー


  • ”プロパティ”に LINE_CHANNEL_ACCESS_TOKEN と入力
  • LINE Messaging API を開く
  • Messaging API設定 タブを開く
  • チャンネルアクセストークン(長期) をコピーする
  • ”値”にペースト

  • ”プロパティ”に LINE_LOGIN_CHANNEL_ID と入力
  • ”LINEログイン” チャンネルを開く
  • ”チャンネル基本設定”タブを開く
  • ”チャンネルID”をコピー
  • ”値”にペースト

  • ”プロパティ”に LINE_LOGIN_CHANNEL_SECRET と入力
  • ”チャンネル基本設定”タブを開く
  • ”チャンネルシークレット” をコピー
  • ”値”にペースト

  • ”プロパティ”に LINE_TO_ID と入力
  • ”チャンネル基本設定”タブを開く
  • ”あなたのユーザーID” をコピー
  • ”値”にペースト

QRコード配布

  • LINE MessagingAPIを開く
  • QRコード上で右クリックしてメニューを開く
  • 画像を保存を選択
  • QRコードを配布

(参考ページ)
https://line-sm.com/blog/lineofficial_qr/?inflow_code=gpm&creative=&keyword=&matchtype=&network=x&device=c&cpid=20491370837&adgrid=&type=pmax&gad_source=1&gad_campaignid=20495665360&gbraid=0AAAAACUbiTgF2TxOa6zGG4HFbhKTDrrc_&gclid=Cj0KCQjwo63HBhCKARIsAHOHV_XFFU3bo2yJvsEfZBwQYsJNnWQ7fXd61mYEjiFfQBkbXQSVMILXf3YaAqiJEALw_wcB

Discussion