Vue.js問い合わせフォームを強化!reCAPTCHA利用有無の切り替えと項目追加の実装

1. はじめに

この記事では、Vue.js を使用した問い合わせフォームの機能拡張について解説します。具体的には、以下のポイントを中心に変更と追加を行いました。

  1. reCAPTCHA利用有無の切り替え機能を追加 – 環境変数からreCAPTCHAを有効・無効に設定できる機能を導入しました。
  2. 項目追加と柔軟な選択肢管理 – 区分や法人名、問い合わせ内容などの項目を追加し、柔軟に選択肢を管理できる仕組みを構築しました。
  3. コードのモジュール化 – JavaScriptコードを外部ファイルに分け、管理しやすくしました。
  4. データベーススキーマの拡張 – 新しい項目に対応するデータベーステーブルの構成を変更しました。

2. プロジェクト構成の確認

プロジェクトは以下の構成で管理されています。

api/
├── config/
│   ├── config.php
│   ├── Env.php
├── controllers/
│   ├── AppController.php
│   ├── ContactController.php
│   ├── CsrfController.php
│   ├── ErrorController.php
│   └── RecaptchaController.php
├── logs/
├── models/
│   └── ContactModel.php
├── services/
│   ├── CSRFService.php
│   ├── CsvService.php
│   ├── DatabaseService.php
│   ├── EmailService.php
│   ├── RecaptchaService.php
│   └── ValidationService.php
├── storage/
├── .env
├── .env.example
├── .htaccess
├── routes.php
js/
│   ├── csrf-recaptcha.js
│   └── form-app.js
.htaccess
index.html

3. フォーム画面のサンプル

以下は問い合わせフォームの実際の画面サンプルです。


4. 環境設定管理とreCAPTCHA利用有無の切り替え機能

環境設定管理 (Env.php)

フォームのセキュリティ設定や動作モードを動的に管理するために、Env.php を用いて環境変数を扱うようにしました。

class Env {
    public static function get($key, $default = null) {
        $env = parse_ini_file(__DIR__ . '/../.env');
        return $env[$key] ?? $default;
    }
}

環境設定例 (.env):

RECAPTCHA_ENABLED=true
RECAPTCHA_SITE_KEY=your-site-key
RECAPTCHA_SECRET_KEY=your-secret-key

利用有無の切り替え機能 (RecaptchaService.php)

$this->enabled = Env::get('RECAPTCHA_ENABLED', false) === 'true';

5. reCAPTCHAとCSRF管理の外部ファイル化 (csrf-recaptcha.js)

コード例 (csrf-recaptcha.js):

const API_URL = "/api";

let recaptchaEnabled = false;

// CSRFトークンの取得
fetch(`${API_URL}/csrf-token`)
  .then(response => response.json())
  .then(data => localStorage.setItem('csrfToken', data.csrf_token))
  .catch(() => console.error('CSRFトークンの取得に失敗しました。'));

// reCAPTCHAの有効状態を確認
fetch(`${API_URL}/recaptcha-enabled`, {
  method: 'GET',
})
  .then(response => response.json())
  .then(data => {
    recaptchaEnabled = data.enabled;
    if (recaptchaEnabled) {
      fetch(`${API_URL}/recaptcha-key`, {
        method: 'GET',
      })
        .then(response => response.json())
        .then(data => {
          if (data.site_key) {
            const script = document.createElement('script');
            script.src = `https://www.google.com/recaptcha/api.js?render=${data.site_key}`;
            script.async = true;
            script.defer = true;
            script.onload = () => {
              window.recaptchaSiteKey = data.site_key;
              grecaptcha.ready(() => {
                window.recaptchaReady = true;
              });
            };
            document.head.appendChild(script);
          }
        });
    }
  });

6. 入力フォーム機能強化 (form-app.js)

項目追加と設定の柔軟性を考慮し、JavaScriptファイルを以下のように外部化しました。

const { createRouter, createWebHistory } = VueRouter;
const { createApp } = Vue;

const config = {
  types: [
    { value: 'corporation', label: '法人' },
    { value: 'individual', label: '個人' }
  ],
  inquiryTypes: [
    { value: 'support', label: 'サポート' },
    { value: 'sales', label: '営業' },
    { value: 'other', label: 'その他' }
  ]
};

const InputForm = {
  template: `
    <div>
      <h1 class="text-2xl font-bold mb-4">お問い合わせフォーム</h1>
      <form @submit.prevent="goToConfirm">
        <div class="mb-4">
          <label class="block text-sm font-medium text-gray-700">区分:</label>
          <div class="flex space-x-4">
            <label v-for="type in config.types" :key="type.value" class="inline-flex items-center">
              <input v-model="formData.type" type="radio" :value="type.value" required class="form-radio h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500">
              <span class="ml-2">{{ type.label }}</span>
            </label>
          </div>
        </div>
        <div v-if="formData.type === 'corporation'" class="mb-4">
          <label for="companyName" class="block text-sm font-medium text-gray-700">法人名:</label>
          <input v-model="formData.companyName" type="text" id="companyName" class="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
        </div>
        <div class="mb-4">
          <label for="name" class="block text-sm font-medium text-gray-700">名前:</label>
          <input v-model="formData.name" type="text" id="name" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
        </div>
        <div class="mb-4">
          <label for="email" class="block text-sm font-medium text-gray-700">メールアドレス:</label>
          <input v-model="formData.email" type="email" id="email" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
        </div>
        <div class="mb-4">
          <label for="phone" class="block text-sm font-medium text-gray-700">電話番号:</label>
          <input v-model="formData.phone" type="tel" id="phone" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
        </div>
        <div class="mb-4">
          <label for="inquiryType" class="block text-sm font-medium text-gray-700">問い合わせ内容:</label>
          <select v-model="formData.inquiryType" id="inquiryType" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm">
            <option v-for="inquiry in config.inquiryTypes" :key="inquiry.value" :value="inquiry.value">{{ inquiry.label }}</option>
          </select>
        </div>
        <div class="mb-4">
          <label for="message" class="block text-sm font-medium text-gray-700">お問い合わせ内容:</label>
          <textarea v-model="formData.message" id="message" required class="mt-1 block w-full border-gray-300 rounded-md shadow-sm"></textarea>
        </div>
        <button type="submit" class="w-full bg-indigo-600 text-white py-2 px-4 rounded">確認画面へ</button>
      </form>
    </div>
  `,
  data() {
    return {
      formData: JSON.parse(localStorage.getItem('formData')) || { name: '', email: '', phone: '', type: '', companyName: '', inquiryType: '', message: '' },
      config
    };
  },
  methods: {
    goToConfirm() {
      localStorage.setItem('formData', JSON.stringify(this.formData));
      this.$router.push({ name: 'confirm' });
    },
  },
};

const ConfirmForm = {
  template: `
    <div>
      <h1 class="text-2xl font-bold mb-4">内容の確認</h1>
      <p><strong>区分:</strong> {{ getLabel('types', formData.type) }}</p>
      <p v-if="formData.type === 'corporation'"><strong>法人名:</strong> {{ formData.companyName }}</p>
      <p><strong>名前:</strong> {{ formData.name }}</p>
      <p><strong>メールアドレス:</strong> {{ formData.email }}</p>
      <p><strong>電話番号:</strong> {{ formData.phone }}</p>
      <p><strong>問い合わせ内容:</strong> {{ getLabel('inquiryTypes', formData.inquiryType) }}</p>
      <p><strong>メッセージ:</strong></p>
      <pre>{{ formData.message }}</pre>
      <div class="flex space-x-4 mt-4">
        <button @click="goBack" class="w-1/2 bg-gray-600 text-white py-2 px-4 rounded">戻る</button>
        <button @click="sendForm" class="w-1/2 bg-indigo-600 text-white py-2 px-4 rounded">送信する</button>
      </div>
    </div>
  `,
  data() {
    return {
      formData: JSON.parse(localStorage.getItem('formData')) || { name: '', email: '', phone: '', type: '', companyName: '', inquiryType: '', message: '' },
      config
    };
  },
  methods: {
    goBack() {
      this.$router.push({ name: 'input' });
    },
    getLabel(key, value) {
      const option = this.config[key].find(opt => opt.value === value);
      return option ? option.label : value;
    },    
    async sendForm() {
      try {
        const csrfToken = localStorage.getItem('csrfToken');
        let recaptchaToken = null;

        // reCAPTCHAトークン生成処理を復元
        if (window.recaptchaReady && window.recaptchaSiteKey) {
          recaptchaToken = await grecaptcha.execute(window.recaptchaSiteKey, { action: 'submit' });
        }

        const bodyData = {
          ...this.formData,
          ...(recaptchaToken ? { recaptcha_token: recaptchaToken } : {})
        };

        const response = await fetch(`${API_URL}/contact`, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': csrfToken,
          },
          body: JSON.stringify(bodyData),
        });
        const data = await response.json();
        if (data.success) {
          localStorage.removeItem('formData');
          this.$router.push({ name: 'complete' });
        } else {
          alert('送信に失敗しました: ' + data.error);
        }
      } catch (error) {
        alert('エラーが発生しました: ' + error.message);
      }
    },
  },
};

const CompleteForm = {
  template: `
    <div>
      <h1 class="text-2xl font-bold mb-4">送信が完了しました</h1>
      <p>お問い合わせいただきありがとうございます。</p>
      <button @click="goToInput" class="mt-4 w-full bg-indigo-600 text-white py-2 px-4 rounded">フォームに戻る</button>
    </div>
  `,
  methods: {
    goToInput() {
      this.$router.push({ name: 'input' });
    },
  },
};

const routes = [
  { path: '/', name: 'input', component: InputForm },
  { path: '/confirm', name: 'confirm', component: ConfirmForm },
  { path: '/complete', name: 'complete', component: CompleteForm },
];

const router = createRouter({ history: createWebHistory(), routes });
const app = createApp({});
app.use(router);
app.mount('#app');

7. データベーススキーマ変更 (SQL)

SQL例:

ALTER TABLE submissions
ADD COLUMN type VARCHAR(20) NOT NULL AFTER id,
ADD COLUMN company_name VARCHAR(255) NULL AFTER type,
ADD COLUMN phone VARCHAR(20) NOT NULL AFTER email,
ADD COLUMN inquiry_type VARCHAR(50) NOT NULL AFTER phone;

8. まとめ

改善ポイント:

  1. reCAPTCHAの利用有無を簡単に切り替えられるように改善。
  2. 入力項目とデータ管理を最適化。
  3. 管理しやすいコード構成に改善し、拡張性を強化。

コメント

タイトルとURLをコピーしました