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:
1108
assets/css/form.css
1108
assets/css/form.css
File diff suppressed because it is too large
Load Diff
@@ -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">·</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') + ' · ' + 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();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user