feat: add standalone form page, close all audit gaps, pass v1.0 milestone

Add standalone form page template that bypasses the theme, with admin
setting and auto-creation on plugin activation. Fix reCAPTCHA v3 double
submission, remove jQuery dependency, extend localized JS strings, and
overhaul form CSS/JS. Update milestone audit to PASSED (9/9, 10/10, 5/5).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 12:08:52 +09:00
parent a9b1f2eb40
commit c0021befe2
14 changed files with 2382 additions and 1393 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,358 +1,565 @@
/**
* Umzugsliste Form JavaScript
* Umzugsliste Wizard Form Engine
*
* Real-time volume (cbm) calculations matching legacy logic
* Vanilla JS multi-step wizard with CBM calculations,
* validation, and summary generation. No jQuery.
*
* @package Umzugsliste
*/
(function($) {
(function() {
'use strict';
// Localized strings with fallbacks
var l10n = typeof umzugslisteL10n !== 'undefined' ? umzugslisteL10n : {
fieldRequired: 'This field is required',
invalidEmail: 'Please enter a valid email address',
selectMovingDate: 'Please select a complete moving date',
enterFurnitureItem: 'Please enter at least one furniture item'
};
var l10n = typeof umzugslisteL10n !== 'undefined' ? umzugslisteL10n : {};
var TOTAL_STEPS = 9;
var currentStep = 1;
// ===== Utility Helpers =====
/**
* Parse German decimal format to float
* Converts "0,40" or "0.40" to 0.40
*
* @param {string|number} str Value to parse
* @return {number} Parsed number or 0
*/
function parseGermanDecimal(str) {
if (!str || str === '') {
return 0;
}
// Convert to string and trim
if (!str || str === '') return 0;
str = String(str).trim().replace(',', '.');
// Parse as float
const num = parseFloat(str);
// Return 0 for invalid or negative numbers
var num = parseFloat(str);
return isNaN(num) || num < 0 ? 0 : num;
}
/**
* Format number to German decimal format
* Converts 0.40 to "0,40"
*
* @param {number} num Number to format
* @param {number} decimals Number of decimal places (default 2)
* @return {string} Formatted number string
*/
function formatGermanDecimal(num, decimals) {
decimals = decimals || 2;
return num.toFixed(decimals).replace('.', ',');
}
/**
* Calculate total cbm for a single furniture item
*
* @param {string|number} quantity Item quantity
* @param {string|number} cbm CBM value per item
* @return {number} Total cbm for this item
*/
function calculateItemTotal(quantity, cbm) {
const qty = parseGermanDecimal(quantity);
const cbmVal = parseGermanDecimal(cbm);
return qty * cbmVal;
function qs(sel, ctx) {
return (ctx || document).querySelector(sel);
}
/**
* Calculate totals for a single room
*
* @param {string} roomKey Room identifier (e.g., "wohnzimmer")
* @return {object} Object with quantity and cbm totals
*/
function qsa(sel, ctx) {
return (ctx || document).querySelectorAll(sel);
}
function escHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// ===== Wizard Navigation =====
function showStep(n) {
if (n < 1 || n > TOTAL_STEPS) return;
// Hide all steps
qsa('.wizard-step').forEach(function(el) {
el.classList.remove('active');
});
// Show target step
var target = qs('.wizard-step[data-step="' + n + '"]');
if (target) target.classList.add('active');
currentStep = n;
updateProgressBar();
updateNavButtons();
updateRunningTotalsVisibility();
// Generate summary when entering step 9
if (n === TOTAL_STEPS) {
generateSummary();
}
// Scroll to top smoothly
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function nextStep() {
if (currentStep === 1 && !validateStep1()) return;
if (currentStep < TOTAL_STEPS) {
showStep(currentStep + 1);
}
}
function prevStep() {
if (currentStep > 1) {
showStep(currentStep - 1);
}
}
function updateProgressBar() {
var dots = qsa('.progress-dot');
dots.forEach(function(dot) {
var step = parseInt(dot.getAttribute('data-step'), 10);
dot.classList.remove('active', 'completed');
if (step === currentStep) {
dot.classList.add('active');
} else if (step < currentStep) {
dot.classList.add('completed');
}
});
// Update progress fill
var fill = qs('#progress-fill');
if (fill) {
var pct = ((currentStep - 1) / (TOTAL_STEPS - 1)) * 100;
fill.style.width = pct + '%';
}
}
function updateNavButtons() {
var backBtn = qs('#wizard-back');
var nextBtn = qs('#wizard-next');
var submitBtn = qs('#wizard-submit');
if (backBtn) backBtn.style.display = currentStep > 1 ? '' : 'none';
if (nextBtn) nextBtn.style.display = currentStep < TOTAL_STEPS ? '' : 'none';
if (submitBtn) submitBtn.style.display = currentStep === TOTAL_STEPS ? '' : 'none';
}
function updateRunningTotalsVisibility() {
var bar = qs('#running-totals');
if (!bar) return;
// Show running totals on room steps (2-7)
if (currentStep >= 2 && currentStep <= 7) {
bar.classList.add('visible');
} else {
bar.classList.remove('visible');
}
}
// ===== CBM Calculations =====
function calculateRoomTotal(roomKey) {
let totalCbm = 0;
let totalQuantity = 0;
var totalCbm = 0;
var totalQty = 0;
// Find all furniture rows for this room
$('tr[data-room="' + roomKey + '"].furniture-row').each(function() {
const $row = $(this);
const quantity = $row.find('.quantity-input').val();
const cbm = $row.data('cbm');
const qty = parseGermanDecimal(quantity);
totalQuantity += qty;
totalCbm += calculateItemTotal(quantity, cbm);
qsa('.furniture-item[data-room="' + roomKey + '"]').forEach(function(item) {
var input = qs('.quantity-input', item);
var qty = parseGermanDecimal(input ? input.value : '');
var cbm = parseFloat(item.getAttribute('data-cbm') || '0');
totalQty += qty;
totalCbm += qty * cbm;
});
// Round to 2 decimal places
return {
quantity: totalQuantity,
quantity: totalQty,
cbm: Math.round(totalCbm * 100) / 100
};
}
/**
* Calculate grand totals across all rooms
*
* @return {object} Object with quantity and cbm totals
*/
function calculateGrandTotal() {
let totalCbm = 0;
let totalQuantity = 0;
var totalCbm = 0;
var totalQty = 0;
var rooms = ['wohnzimmer', 'schlafzimmer', 'arbeitszimmer', 'bad', 'kueche_esszimmer', 'kinderzimmer', 'keller'];
// Sum all room totals
$('.room-totals').each(function() {
const $row = $(this);
const roomKey = $row.closest('table').data('room');
const roomTotal = calculateRoomTotal(roomKey);
totalQuantity += roomTotal.quantity;
totalCbm += roomTotal.cbm;
rooms.forEach(function(room) {
var t = calculateRoomTotal(room);
totalQty += t.quantity;
totalCbm += t.cbm;
});
// Round to 2 decimal places
return {
quantity: totalQuantity,
quantity: totalQty,
cbm: Math.round(totalCbm * 100) / 100
};
}
/**
* Update display for a single room's totals
*
* @param {string} roomKey Room identifier
*/
function updateRoomDisplay(roomKey) {
const total = calculateRoomTotal(roomKey);
const $table = $('table[data-room="' + roomKey + '"]');
$table.find('.room-total-quantity').text(total.quantity);
$table.find('.room-total-cbm').text(formatGermanDecimal(total.cbm));
var total = calculateRoomTotal(roomKey);
qsa('.room-totals[data-room="' + roomKey + '"]').forEach(function(el) {
var qtyEl = qs('.room-total-quantity', el);
var cbmEl = qs('.room-total-cbm', el);
if (qtyEl) qtyEl.textContent = total.quantity;
if (cbmEl) cbmEl.textContent = formatGermanDecimal(total.cbm);
});
}
/**
* Update grand totals display
*/
function updateGrandTotalDisplay() {
const total = calculateGrandTotal();
$('#grand-total-quantity').text(total.quantity);
$('#grand-total-cbm').text(formatGermanDecimal(total.cbm));
}
/**
* Update all totals (rooms and grand total)
*/
function updateAllTotals() {
function updateRunningTotals() {
// Update each room
$('.room-totals').each(function() {
const roomKey = $(this).closest('table').data('room');
updateRoomDisplay(roomKey);
var rooms = ['wohnzimmer', 'schlafzimmer', 'arbeitszimmer', 'bad', 'kueche_esszimmer', 'kinderzimmer', 'keller'];
rooms.forEach(updateRoomDisplay);
// Update running totals bar
var grand = calculateGrandTotal();
var qtyEl = qs('#running-total-qty');
var cbmEl = qs('#running-total-cbm');
if (qtyEl) qtyEl.textContent = grand.quantity;
if (cbmEl) cbmEl.textContent = formatGermanDecimal(grand.cbm);
}
// ===== Validation =====
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function showFieldError(field, message) {
field.classList.add('field-error');
clearFieldError(field);
var span = document.createElement('span');
span.className = 'error-message';
span.textContent = message;
field.parentNode.insertBefore(span, field.nextSibling);
}
function clearFieldError(field) {
field.classList.remove('field-error');
var next = field.nextElementSibling;
if (next && next.classList.contains('error-message')) {
next.remove();
}
}
function validateStep1() {
var valid = true;
var step = qs('.wizard-step[data-step="1"]');
if (!step) return true;
// Clear all errors first
qsa('.field-error', step).forEach(function(el) {
clearFieldError(el);
});
qsa('.error-message', step).forEach(function(el) {
el.remove();
});
// Update grand total
updateGrandTotalDisplay();
// Validate required fields
qsa('input[required]', step).forEach(function(input) {
var val = input.value.trim();
if (!val) {
showFieldError(input, l10n.fieldRequired || 'This field is required');
valid = false;
} else if (input.name === 'info[eE-Mail]' && !validateEmail(val)) {
showFieldError(input, l10n.invalidEmail || 'Please enter a valid email address');
valid = false;
}
});
// Scroll to first error
if (!valid) {
var firstErr = qs('.field-error', step);
if (firstErr) {
firstErr.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
return valid;
}
/**
* Handle quantity input change
* Debounced for performance
*/
let debounceTimer;
function handleQuantityChange() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
updateAllTotals();
}, 100); // Quick response (100ms debounce)
function validateFurnitureItems() {
var hasItems = false;
qsa('.quantity-input').forEach(function(input) {
if (parseGermanDecimal(input.value) > 0) {
hasItems = true;
}
});
return hasItems;
}
/**
* Validate email format
*
* @param {string} email Email address to validate
* @return {boolean} True if valid email format
*/
function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
/**
* Validate required field
*
* @param {string} value Field value
* @return {boolean} True if not empty
*/
function validateRequired(value) {
return value && value.trim() !== '';
}
/**
* Show error message for a field
*
* @param {jQuery} $field Field element
* @param {string} message Error message
*/
function showFieldError($field, message) {
// Add error class to field
$field.addClass('field-error');
// Remove existing error message if any
clearFieldError($field);
// Add error message after field
$field.after('<span class="error-message">' + message + '</span>');
}
/**
* Clear error message for a field
*
* @param {jQuery} $field Field element
*/
function clearFieldError($field) {
$field.removeClass('field-error');
$field.next('.error-message').remove();
}
/**
* Validate a single field
*
* @param {jQuery} $field Field element
* @return {boolean} True if valid
*/
function validateField($field) {
const fieldName = $field.attr('name');
const value = $field.val();
const isRequired = $field.attr('required') !== undefined;
// Clear existing errors
clearFieldError($field);
// Check required fields
if (isRequired && !validateRequired(value)) {
showFieldError($field, l10n.fieldRequired);
function validateForm() {
if (!validateStep1()) {
showStep(1);
return false;
}
// Check email format
if (fieldName === 'info[eE-Mail]' && value) {
if (!validateEmail(value)) {
showFieldError($field, l10n.invalidEmail);
return false;
}
if (!validateFurnitureItems()) {
alert(l10n.enterFurnitureItem || 'Please enter at least one furniture item');
return false;
}
return true;
}
/**
* Validate all furniture items - at least one must have quantity
*
* @return {boolean} True if valid
*/
function validateFurnitureItems() {
let hasItems = false;
// ===== Summary Generation =====
$('.quantity-input').each(function() {
const qty = parseGermanDecimal($(this).val());
if (qty > 0) {
hasItems = true;
return false; // break loop
}
function generateSummary() {
var container = qs('#wizard-summary');
if (!container) return;
var html = '';
// Customer info
html += '<div class="summary-section">';
html += '<h3>' + escHtml(l10n.summaryMovingDate || 'Moving Date') + '</h3>';
var day = getFieldVal('day');
var month = getFieldVal('month');
var year = getFieldVal('year');
html += summaryRow(l10n.summaryMovingDate || 'Moving Date', day + '.' + month + '.' + year);
html += '</div>';
// Loading address
html += '<div class="summary-section">';
html += '<h3>' + escHtml(l10n.summaryLoading || 'Loading Address') + '</h3>';
html += summaryRow('Name', getFieldVal('bName'));
html += summaryRow('Street', getFieldVal('bStrasse'));
html += summaryRow('ZIP/City', getFieldVal('bort'));
var bGeschoss = getFieldVal('info[bGeschoss]');
if (bGeschoss) html += summaryRow('Floor', bGeschoss);
html += summaryRow('Elevator', getRadioVal('info[bLift]'));
html += summaryRow('Phone', getFieldVal('bTelefon'));
var bFax = getFieldVal('info[bTelefax]');
if (bFax) html += summaryRow('Fax', bFax);
var bMobil = getFieldVal('info[bMobil]');
if (bMobil) html += summaryRow('Mobile', bMobil);
html += summaryRow('Email', getFieldVal('info[eE-Mail]'));
html += '</div>';
// Unloading address
html += '<div class="summary-section">';
html += '<h3>' + escHtml(l10n.summaryUnloading || 'Unloading Address') + '</h3>';
html += summaryRow('Name', getFieldVal('eName'));
html += summaryRow('Street', getFieldVal('eStrasse'));
html += summaryRow('ZIP/City', getFieldVal('eort'));
var eGeschoss = getFieldVal('info[eGeschoss]');
if (eGeschoss) html += summaryRow('Floor', eGeschoss);
html += summaryRow('Elevator', getRadioVal('info[eLift]'));
var eTel = getFieldVal('eTelefon');
if (eTel) html += summaryRow('Phone', eTel);
var eFax = getFieldVal('info[eTelefax]');
if (eFax) html += summaryRow('Fax', eFax);
var eMobil = getFieldVal('info[eMobil]');
if (eMobil) html += summaryRow('Mobile', eMobil);
html += '</div>';
// Room summaries
var roomMap = [
{ key: 'wohnzimmer', name: 'Wohnzimmer' },
{ key: 'schlafzimmer', name: 'Schlafzimmer' },
{ key: 'arbeitszimmer', name: 'Arbeitszimmer' },
{ key: 'bad', name: 'Bad' },
{ key: 'kueche_esszimmer', name: 'Kueche_Esszimmer' },
{ key: 'kinderzimmer', name: 'Kinderzimmer' },
{ key: 'keller', name: 'Keller' }
];
roomMap.forEach(function(room) {
var roomItems = getRoomSummaryItems(room.key);
if (roomItems.length === 0) return;
var total = calculateRoomTotal(room.key);
html += '<div class="summary-section">';
html += '<h3>' + escHtml(getRoomDisplayName(room.key)) + '</h3>';
roomItems.forEach(function(item) {
html += '<div class="summary-item">';
html += '<span class="summary-item-name">' + escHtml(item.name) + '</span>';
html += '<span class="summary-item-qty">' + item.qty + '</span>';
html += '<span class="summary-item-cbm">' + formatGermanDecimal(item.cbm) + '</span>';
if (item.montage !== null) {
html += '<span class="summary-item-montage">' + escHtml(item.montage === 'ja' ? (l10n.summaryYes || 'Yes') : (l10n.summaryNo || 'No')) + '</span>';
}
html += '</div>';
});
html += '<div class="room-totals">';
html += '<span class="room-total-label">' + escHtml(l10n.totalLabel || 'Total') + ':</span> ';
html += '<span class="room-total-quantity">' + total.quantity + '</span> ' + escHtml(l10n.summaryItems || 'Items');
html += ' <span class="room-totals-sep">&middot;</span> ';
html += '<span class="room-total-cbm">' + formatGermanDecimal(total.cbm) + '</span> ' + escHtml(l10n.summaryCbm || 'cbm');
html += '</div>';
html += '</div>';
});
return hasItems;
}
// Grand total
var grand = calculateGrandTotal();
html += '<div class="summary-grand-total">';
html += '<span>' + escHtml(l10n.grandTotalLabel || 'Grand Total') + '</span>';
html += '<span>' + grand.quantity + ' ' + escHtml(l10n.summaryItems || 'Items') + ' &middot; ' + formatGermanDecimal(grand.cbm) + ' ' + escHtml(l10n.summaryCbm || 'cbm') + '</span>';
html += '</div>';
/**
* Validate date fields
*
* @return {boolean} True if valid date selected
*/
function validateDate() {
const day = $('select[name="day"]').val();
const month = $('select[name="month"]').val();
const year = $('select[name="year"]').val();
return day && month && year;
}
/**
* Validate entire form before submission
*
* @return {boolean} True if all validations pass
*/
function validateForm() {
let isValid = true;
const errors = [];
// Validate date
if (!validateDate()) {
errors.push(l10n.selectMovingDate);
isValid = false;
// Additional work summary
var additionalHtml = getAdditionalWorkSummary();
if (additionalHtml) {
html += '<div class="summary-section">';
html += '<h3>' + escHtml(l10n.summaryAdditional || 'Additional Work') + '</h3>';
html += additionalHtml;
html += '</div>';
}
// Validate required fields
$('input[required]').each(function() {
if (!validateField($(this))) {
isValid = false;
}
});
// Validate furniture items
if (!validateFurnitureItems()) {
errors.push(l10n.enterFurnitureItem);
isValid = false;
// Scroll to first room table
if ($('.quantity-input:first').length) {
$('html, body').animate({
scrollTop: $('.quantity-input:first').closest('table').offset().top - 100
}, 500);
}
// Sonstiges
var sonstiges = getFieldVal('sonstiges');
if (sonstiges) {
html += '<div class="summary-section">';
html += '<h3>' + escHtml(l10n.summaryOther || 'Other') + '</h3>';
html += '<p>' + escHtml(sonstiges) + '</p>';
html += '</div>';
}
// If there are general errors, scroll to first error field
if (!isValid && $('.field-error:first').length) {
$('html, body').animate({
scrollTop: $('.field-error:first').offset().top - 100
}, 500);
}
return isValid;
container.innerHTML = html;
}
/**
* Initialize calculations
*/
$(document).ready(function() {
// Attach event listeners to all quantity inputs
$('.quantity-input').on('input change', handleQuantityChange);
function summaryRow(label, value) {
return '<div class="summary-row"><span class="summary-row-label">' + escHtml(label) + '</span><span class="summary-row-value">' + escHtml(value || '-') + '</span></div>';
}
// Initial calculation (in case of pre-filled values)
updateAllTotals();
function getFieldVal(name) {
var el = qs('[name="' + name + '"]');
if (!el) return '';
if (el.tagName === 'SELECT') return el.options[el.selectedIndex].value;
return el.value.trim();
}
// Attach validation listeners
$('input[required], input[type="email"]').on('blur', function() {
validateField($(this));
function getRadioVal(name) {
var checked = qs('input[name="' + name + '"]:checked');
return checked ? checked.value : '';
}
function getRoomDisplayName(roomKey) {
// Read from the step title or the furniture list heading
var list = qs('.furniture-list[data-room="' + roomKey + '"]');
if (list) {
var card = list.closest('.step-card');
if (card) {
var h3 = qs('h3', card);
if (h3) return h3.textContent;
}
}
return roomKey;
}
function getRoomSummaryItems(roomKey) {
var items = [];
qsa('.furniture-item[data-room="' + roomKey + '"]').forEach(function(el) {
var input = qs('.quantity-input', el);
var qty = parseGermanDecimal(input ? input.value : '');
if (qty <= 0) return;
var nameEl = qs('.item-name', el);
var cbmVal = parseFloat(el.getAttribute('data-cbm') || '0');
// Check montage
var montage = null;
var montageRadio = qs('.montage-toggle input[value="ja"]', el);
if (montageRadio) {
montage = montageRadio.checked ? 'ja' : 'nein';
}
items.push({
name: nameEl ? nameEl.textContent : '',
qty: qty,
cbm: qty * cbmVal,
montage: montage
});
});
return items;
}
// Clear error on field change
$('input').on('input', function() {
clearFieldError($(this));
function getAdditionalWorkSummary() {
var html = '';
qsa('.additional-work-section').forEach(function(section) {
var sectionItems = [];
qsa('.additional-field', section).forEach(function(field) {
var checkbox = qs('input[type="checkbox"]', field);
if (checkbox && checkbox.checked) {
var label = checkbox.parentNode.textContent.trim();
var qtyInput = qs('.qty-small', field);
var qtyVal = qtyInput ? qtyInput.value.trim() : '';
sectionItems.push(label + (qtyVal ? ' (' + qtyVal + ')' : ''));
}
var radio = qs('input[type="radio"]:checked', field);
if (radio && !checkbox) {
var fieldLabel = qs('.additional-field-label', field);
if (fieldLabel) {
sectionItems.push(fieldLabel.textContent.trim() + ': ' + radio.value);
}
}
// Text-only fields (no checkbox, no radio)
if (!checkbox && !radio) {
var textInput = qs('input[type="text"]', field);
if (textInput && textInput.value.trim()) {
var textLabel = qs('label', field);
sectionItems.push((textLabel ? textLabel.textContent.trim() : '') + ': ' + textInput.value.trim());
}
}
});
if (sectionItems.length > 0) {
sectionItems.forEach(function(item) {
html += '<div class="summary-row"><span class="summary-row-value">' + escHtml(item) + '</span></div>';
});
}
});
return html;
}
// Validate form on submit
$('#umzugsliste-form').on('submit', function(e) {
if (!validateForm()) {
// ===== Event Handling =====
var debounceTimer;
function handleQuantityChange() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
updateRunningTotals();
}, 100);
}
function init() {
// Nav buttons
var nextBtn = qs('#wizard-next');
var backBtn = qs('#wizard-back');
var form = qs('#umzugsliste-form');
if (nextBtn) {
nextBtn.addEventListener('click', function(e) {
e.preventDefault();
return false;
nextStep();
});
}
if (backBtn) {
backBtn.addEventListener('click', function(e) {
e.preventDefault();
prevStep();
});
}
// Progress dot click (backward navigation only)
qsa('.progress-dot').forEach(function(dot) {
dot.addEventListener('click', function() {
var step = parseInt(this.getAttribute('data-step'), 10);
if (step < currentStep) {
showStep(step);
}
});
});
// Quantity input handlers via event delegation
document.addEventListener('input', function(e) {
if (e.target.classList.contains('quantity-input')) {
handleQuantityChange();
// Toggle has-value class
if (parseGermanDecimal(e.target.value) > 0) {
e.target.classList.add('has-value');
} else {
e.target.classList.remove('has-value');
}
}
});
console.log('Umzugsliste calculations and validation initialized');
});
// Clear field errors on input
document.addEventListener('input', function(e) {
if (e.target.classList.contains('field-error')) {
clearFieldError(e.target);
}
});
})(jQuery);
// Form submit
if (form) {
form.addEventListener('submit', function(e) {
if (!validateForm()) {
e.preventDefault();
return false;
}
});
}
// Initialize display
showStep(1);
updateRunningTotals();
}
// ===== Boot =====
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();