//=============================================================== // form-full //=============================================================== (function() { "use strict"; function initAjaxForm(form) { var actionUrl = form.getAttribute("action") || "form.php"; var submitButton = form.querySelector('button[type="submit"]'); var globalErrorBox = findSiblingBox(form, ".form-global-error"); var successBox = findSiblingBox(form, ".form-success-box"); var privacyRow = form.querySelector(".js-privacy-row"); var attachmentRow = form.querySelector(".js-attachment-row"); var attachmentInput = attachmentRow ? attachmentRow.querySelector('input[type="file"]') : null; var csrfInput = form.querySelector('input[name="csrf_token"]'); if (!submitButton || !csrfInput) { return; } initDateSelectGroups(form); function applyPreviewFallback() { var previewPrivacy = form.getAttribute("data-preview-privacy") === "true"; var previewAttachment = form.getAttribute("data-preview-attachment") === "true"; togglePrivacyRow(previewPrivacy); toggleAttachmentRow(previewAttachment); } fetchInitData(); form.addEventListener("submit", function(event) { event.preventDefault(); /* ローカルの通常プレビュー(file://)では送信しない */ if (window.location.protocol === "file:") { return; } clearErrors(); hideGlobalError(); submitButton.disabled = true; var hadSelectedFile = attachmentInput && attachmentInput.files && attachmentInput.files.length > 0; var formData = new FormData(form); fetch(actionUrl, { method: "POST", body: formData, headers: { "X-Requested-With": "XMLHttpRequest" } }) .then(function(response) { return response.json(); }) .then(function(json) { if (json && json.csrfToken) { csrfInput.value = json.csrfToken; } if (json && json.ok) { showSuccess(); return; } resetAttachmentOnError(hadSelectedFile); if (json && json.fieldErrors) { applyFieldErrors(json.fieldErrors); } if (json && json.formError) { showGlobalError(json.formError); } }) .catch(function() { resetAttachmentOnError(false); showGlobalError("送信に失敗しました。時間をおいて再度お試しください。"); }) .finally(function() { submitButton.disabled = false; }); }); function fetchInitData() { /* ローカルの通常プレビュー(file://)では初期化通信を行わず、HTML側のプレビュー設定を使う */ if (window.location.protocol === "file:") { applyPreviewFallback(); return; } fetch(actionUrl + "?action=init", { method: "GET", headers: { "X-Requested-With": "XMLHttpRequest" } }) .then(function(response) { /* form.php が存在しない(404等)場合はプレビューモードにフォールバック */ if (!response.ok) { applyPreviewFallback(); return; } return response.json(); }) .then(function(json) { if (!json) { return; } if (!json.ok) { showGlobalError("フォームの初期化に失敗しました。ページを再読み込みしてください。", false); return; } if (json.csrfToken) { csrfInput.value = json.csrfToken; } togglePrivacyRow(json.privacyConsentEnabled !== false); toggleAttachmentRow(json.attachmentEnabled !== false); }) .catch(function() { /* ネットワークエラーやJSONパース失敗時もプレビューモードにフォールバック */ applyPreviewFallback(); }); } function togglePrivacyRow(isEnabled) { if (!privacyRow) { return; } privacyRow.hidden = !isEnabled; var privacyInput = privacyRow.querySelector('input[name="privacy_consent"]'); if (!privacyInput) { return; } if (!isEnabled) { privacyInput.checked = false; } privacyInput.disabled = !isEnabled; } function toggleAttachmentRow(isEnabled) { if (!attachmentRow) { return; } attachmentRow.hidden = !isEnabled; if (!attachmentInput) { return; } if (!isEnabled) { attachmentInput.value = ""; } attachmentInput.disabled = !isEnabled; } function clearErrors() { var rows = form.querySelectorAll(".form-row"); rows.forEach(function(row) { row.classList.remove("is-error"); row.querySelectorAll(".form-error-text, .form-file-reset-text").forEach(function(message) { message.remove(); }); }); } function applyFieldErrors(fieldErrors) { var firstRow = null; var firstInput = null; Object.keys(fieldErrors).forEach(function(fieldName) { var input = form.querySelector('[name="' + cssEscape(fieldName) + '"]') || form.querySelector('[name="' + cssEscape(fieldName) + '[]"]'); var row = input ? input.closest(".form-row") : null; if (!row) { row = form.querySelector('.js-date-select-group[data-field-name="' + cssEscape(fieldName) + '"]'); if (row) { input = row.querySelector("select, input, textarea"); } } if (!row) { return; } row.classList.add("is-error"); var error = document.createElement("p"); error.className = "form-error-text"; error.textContent = fieldErrors[fieldName]; row.appendChild(error); if (!firstRow) { firstRow = row; firstInput = input || null; } }); if (firstInput && typeof firstInput.focus === "function") { try { firstInput.focus({ preventScroll: true }); } catch (error) { firstInput.focus(); } } if (firstRow) { firstRow.scrollIntoView({ behavior: "smooth", block: "center" }); } } function resetAttachmentOnError(hadSelectedFile) { if (!attachmentRow || !attachmentInput) { return; } if (attachmentInput.value !== "") { attachmentInput.value = ""; } if (!hadSelectedFile) { return; } var note = document.createElement("p"); note.className = "form-file-reset-text"; note.textContent = "安全のため、添付ファイルは再選択してください。"; attachmentRow.appendChild(note); } function showGlobalError(message, shouldScroll) { if (!globalErrorBox) { return; } if (typeof shouldScroll === "undefined") { shouldScroll = true; } globalErrorBox.textContent = message; globalErrorBox.hidden = false; if (shouldScroll) { globalErrorBox.scrollIntoView({ behavior: "smooth", block: "center" }); } } function hideGlobalError() { if (!globalErrorBox) { return; } globalErrorBox.hidden = true; globalErrorBox.textContent = ""; } function showSuccess() { form.hidden = true; hideGlobalError(); if (successBox) { successBox.hidden = false; successBox.scrollIntoView({ behavior: "smooth", block: "center" }); } } } function initDateSelectGroups(scope) { var groups = scope.querySelectorAll(".js-date-select-group"); groups.forEach(function(group) { var mode = group.getAttribute("data-date-mode"); var yearSelect = group.querySelector('[data-date-part="year"]'); var monthSelect = group.querySelector('[data-date-part="month"]'); var daySelect = group.querySelector('[data-date-part="day"]'); var startYear = parseInt(group.getAttribute("data-year-start"), 10); var endYear = parseInt(group.getAttribute("data-year-end"), 10); if (monthSelect) { populateNumberSelect(monthSelect, 1, 12, "月を選択", "月"); } if (yearSelect) { if (isNaN(startYear)) { startYear = 1900; } if (isNaN(endYear)) { endYear = new Date().getFullYear(); } populateNumberSelect(yearSelect, startYear, endYear, "年を選択", "年", true); } if (daySelect) { updateDayOptions(); } if (monthSelect) { monthSelect.addEventListener("change", updateDayOptions); } if (yearSelect && mode === "ymd") { yearSelect.addEventListener("change", updateDayOptions); } function updateDayOptions() { if (!daySelect) { return; } var maxDay = 31; var month = monthSelect ? parseInt(monthSelect.value, 10) : NaN; var year = yearSelect ? parseInt(yearSelect.value, 10) : new Date().getFullYear(); if (!isNaN(month)) { if (isNaN(year)) { year = new Date().getFullYear(); } maxDay = new Date(year, month, 0).getDate(); } populateNumberSelect(daySelect, 1, maxDay, "日を選択", "日"); } }); } function populateNumberSelect(select, start, end, placeholder, suffix, descending) { if (!select) { return; } var currentValue = select.value; var numbers = []; var i; if (descending) { for (i = end; i >= start; i -= 1) { numbers.push(i); } } else { for (i = start; i <= end; i += 1) { numbers.push(i); } } select.innerHTML = ""; var placeholderOption = document.createElement("option"); placeholderOption.value = ""; placeholderOption.textContent = placeholder; select.appendChild(placeholderOption); numbers.forEach(function(number) { var option = document.createElement("option"); option.value = String(number); option.textContent = String(number) + suffix; select.appendChild(option); }); if (currentValue !== "" && hasOptionValue(select, currentValue)) { select.value = currentValue; } else { select.value = ""; } } function hasOptionValue(select, value) { var options = Array.prototype.slice.call(select.options); return options.some(function(option) { return option.value === value; }); } function findSiblingBox(form, selector) { var parent = form.parentElement; if (!parent) { return null; } return parent.querySelector(selector); } function cssEscape(value) { if (window.CSS && typeof window.CSS.escape === "function") { return window.CSS.escape(value); } return String(value).replace(/"/g, '\\"'); } document.addEventListener("DOMContentLoaded", function() { var forms = document.querySelectorAll(".js-ajax-form"); forms.forEach(initAjaxForm); }); })(); /** * フォームスパム対策:JS人間操作チェック * * フォーム上で「人間らしい操作」(focus / input / change / paste / keydown) を * 1回でも検知したら、隠しフィールド name="human_check" の値を "1" に書き換える。 * サーバー側 (form.php) は human_check が "1" でない送信をボット判定で拒否する。 * * ねらい: * ヘッドレスブラウザが値を直接代入して送信する手口に対して有効。 * 通常のキーボード入力・コピペ・パスワードマネージャー自動入力など * 人間が普通に行う操作はすべてカバーするため、誤判定が起きにくい。 * * 対象フォーム: * form[action="form.php"] / form[action$="/form.php"] / form.js-ajax-form * のいずれかにマッチする全フォーム。 * * 注意: * 送信時に human_check が一緒に POST されている必要があります。 * 独自にAjax送信を組んでいる場合は FormData(form) 等で全フィールドを * 含めるようにしてください(普通の form 送信なら自動で含まれます)。 */ (function () { 'use strict'; function setupHumanCheck(form) { var flagField = form.querySelector('input[name="human_check"]'); if (!flagField) return; var events = ['focus', 'input', 'change', 'paste', 'keydown']; var detected = false; function onInteraction() { if (detected) return; detected = true; flagField.value = '1'; // 役目を終えたのでリスナー解除(パフォーマンス対策) events.forEach(function (ev) { form.removeEventListener(ev, onInteraction, true); }); } events.forEach(function (ev) { // capture: true で、各入力フィールドからのバブルを待たずに拾う form.addEventListener(ev, onInteraction, true); }); } function init() { var forms = document.querySelectorAll( 'form[action="form.php"], form[action$="/form.php"], form.js-ajax-form' ); Array.prototype.forEach.call(forms, setupHumanCheck); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();