1/** 2 * Frontend Conditional Logic for Visual Forms 3 * 4 * Handles dynamic field visibility based on conditional logic rules. 5 * 6 * @package Charitable/Visual Forms 7 * @since 1.8.8 8 */ 9(function($) { 10 'use strict'; 11 12 // Immediate log to confirm script is loaded (before jQuery/DOM ready) 13 (function() { 14 var search = window.location.search; 15 var isDebug = (search.indexOf('visual_debug=true') !== -1) || (search.indexOf('debug_visual=true') !== -1); 16 if (isDebug) { 17 console.log('[ConditionalLogic] Script file loaded and executing'); 18 console.log('[ConditionalLogic] Current URL:', window.location.href); 19 } 20 })(); 21 22 var CharitableConditionalLogic = { 23 initialized: false, 24 eventsBound: false, 25 debugMode: false, 26 forms: {}, 27 rules: {}, 28 debugHistory: [], // Track all debug events 29 fieldValueHistory: {}, // Track field value changes 30 31 /** 32 * Initialize conditional logic 33 */ 34 init: function() { 35 if (this.initialized) { 36 return; 37 } 38 39 // Check for debug mode (support both parameter names) 40 this.debugMode = this.isDebugMode(); 41 42 if (this.debugMode) { 43 console.log('[ConditionalLogic] Frontend script loaded'); 44 console.log('[ConditionalLogic] Debug mode enabled'); 45 console.log('[ConditionalLogic] jQuery available:', typeof $ !== 'undefined'); 46 console.log('[ConditionalLogic] CHARITABLE available:', typeof CHARITABLE !== 'undefined'); 47 } 48 49 // Wait for DOM ready 50 $(document).ready($.proxy(this.onReady, this)); 51 }, 52 53 /** 54 * Get URL parameter 55 */ 56 getUrlParameter: function(name) { 57 name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); 58 var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); 59 var results = regex.exec(location.search); 60 return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); 61 }, 62 63 /** 64 * Check if debug mode is enabled (supports both parameter names) 65 */ 66 isDebugMode: function() { 67 return this.getUrlParameter('visual_debug') === 'true' || this.getUrlParameter('debug_visual') === 'true'; 68 }, 69 70 /** 71 * DOM ready handler 72 */ 73 onReady: function() { 74 if (this.debugMode) { 75 console.log('[ConditionalLogic] DOM ready'); 76 } 77 78 // Check if conditional logic rules are available 79 if (typeof charitable_conditional_logic === 'undefined') { 80 if (this.debugMode) { 81 console.warn('[ConditionalLogic] No conditional logic rules found'); 82 } 83 return; 84 } 85 86 this.rules = charitable_conditional_logic; 87 88 if (this.debugMode) { 89 console.log('[ConditionalLogic] Rules loaded:', this.rules); 90 console.log('[ConditionalLogic] Forms with conditionals:', Object.keys(this.rules)); 91 } 92 93 // Initialize each form 94 for (var formId in this.rules) { 95 if (this.rules.hasOwnProperty(formId)) { 96 this.initForm(formId, this.rules[formId]); 97 } 98 } 99 100 // Bind the delegated field listeners. Extracted so applyRules() can bind 101 // them for a form injected after page load (e.g. into the donation popup), 102 // where onReady() returned early because no rules were present at load. 103 this.bindEvents(); 104 105 // Debug: Monitor all user interactions 106 if (this.debugMode) { 107 this.setupDebugMonitoring(); 108 } 109 110 // Initial evaluation (with delay to ensure form is initialized) 111 var self = this; 112 setTimeout(function() { 113 if (self.debugMode) { 114 console.log('[ConditionalLogic] Running initial evaluation'); 115 } 116 117 // Read all field values from the DOM on initial load 118 self.readAllFieldValues(); 119 120 // Print initial form state 121 if (self.debugMode) { 122 self.printFormState('Initial Load'); 123 } 124 125 // Evaluate all conditionals 126 self.evaluateAll(); 127 }, 200); // Increased delay to ensure form is fully initialized 128 129 this.initialized = true; 130 131 if (this.debugMode) { 132 console.log('[ConditionalLogic] Initialization complete'); 133 } 134 }, 135 136 /** 137 * Bind the delegated change/input listeners once. They live on document, so a 138 * single binding also covers forms injected later (e.g. into the donation
139 * popup). Guarded so repeated modal opens do not stack duplicate handlers. 140 * 141 * @since 1.8.18.1 142 */ 143 bindEvents: function() { 144 if (this.eventsBound) { 145 return; 146 } 147 this.eventsBound = true; 148 149 // Listen for donation amount changes (from charitable.js) 150 $(document.body).on('charitable:form:amount:changed', $.proxy(this.onDonationAmountChanged, this)); 151 152 // Listen directly to donation amount radio button changes 153 $(document).on('change', '.charitable-visual-donation-form input[name="donation_amount"]', $.proxy(this.onDonationAmountRadioChange, this)); 154 155 // Listen to custom donation amount input changes 156 $(document).on('change input', '.charitable-visual-donation-form input.custom-donation-input, .charitable-visual-donation-form input[name="custom_donation_amount"]', $.proxy(this.onCustomAmountChange, this)); 157 158 // Listen for field changes 159 $(document).on('change input', '.charitable-visual-donation-form input, .charitable-visual-donation-form select, .charitable-visual-donation-form textarea', $.proxy(this.onFieldChange, this)); 160 }, 161 162 /** 163 * Apply conditional-logic rules delivered after page load - specifically a form 164 * injected into the donation popup by charitable-universal-modal.js. The footer 165 * var (charitable_conditional_logic) is only printed on a full page render, so 166 * onReady() returns early on a popup-only flow and the evaluator never binds. 167 * The modal hands the campaign's rules here so show/hide fields work in the 168 * popup too. 169 * 170 * @since 1.8.18.1 171 * 172 * @param {Object} newRules Rules keyed by form id, same shape as the footer var. 173 */ 174 applyRules: function(newRules) { 175 if (!newRules || typeof newRules !== 'object') { 176 return; 177 } 178 179 // Keep debug logging working even when onReady() bailed before setting it. 180 this.debugMode = this.debugMode || this.isDebugMode(); 181 182 // Merge into both the global and our own copy so later evaluations see them. 183 if (typeof window.charitable_conditional_logic === 'undefined') { 184 window.charitable_conditional_logic = {}; 185 } 186 187 var formId; 188 for (formId in newRules) { 189 if (newRules.hasOwnProperty(formId)) { 190 window.charitable_conditional_logic[formId] = newRules[formId]; 191 this.rules[formId] = newRules[formId]; 192 this.initForm(formId, newRules[formId]); 193 } 194 } 195 196 // onReady() may have returned before binding when the page loaded with no 197 // rules; bindEvents() is guarded, so this is safe to call on every open. 198 this.bindEvents(); 199 this.initialized = true; 200 201 // Evaluate against the just-injected form so it starts in the right state 202 // (strips charitable-conditional-field-pending and shows/hides fields). 203 var self = this; 204 setTimeout(function() { 205 self.readAllFieldValues(); 206 self.evaluateAll(); 207 }, 50); 208 }, 209 210 /** 211 * Initialize a form 212 */ 213 initForm: function(formId, formRules) { 214 if (this.debugMode) { 215 console.log('[ConditionalLogic] Initializing form:', formId, formRules); 216 } 217 218 this.forms[formId] = { 219 rules: formRules, 220 formElement: null 221 }; 222 223 // Find form element 224 var $form = $('.charitable-visual-donation-form'); 225 if ($form.length) { 226 this.forms[formId].formElement = $form.first(); 227 } 228 }, 229 230 /** 231 * Get current donation amount from form 232 */ 233 getDonationAmount: function() { 234 var amount = 0; 235 236 // Try to get from checked radio button first 237 var $amountInput = $('.charitable-visual-donation-form input[name="donation_amount"]:checked'); 238 if ($amountInput.length) { 239 var amountVal = $amountInput.val(); 240 if (amountVal && amountVal !== 'custom') { 241 amount = parseFloat(amountVal) || 0; 242 } else if (amountVal === 'custom') { 243 // Custom amount selected, get from custom input 244 var $customAmount = $('.charitable-visual-donation-form input.custom-donation-input, .charitable-visual-donation-form input[name="custom_donation_amount"]'); 245 if ($customAmount.length && $customAmount.val()) { 246 var customVal = $customAmount.val().replace(/[^0-9.]/g, ''); 247 if (customVal) { 248 amount = parseFloat(customVal) || 0; 249 } 250 } 251 } 252 } else { 253 // No radio selected, check custom amount input directly 254 var $customAmount = $('.charitable-visual-donation-form input.custom-donation-input, .charitable-visual-donation-form input[name="custom_donation_amount"]'); 255 if ($customAmount.length && $customAmount.val()) { 256 var customVal = $customAmount.val().replace(/[^0-9.]/g, ''); 257 if (customVal) { 258 amount = parseFloat(customVal) || 0; 259 } 260 } 261 } 262 263 return amount; 264 }, 265 266 /** 267 * Handle donation amount change (from charitable:form:amount:changed event) 268 */ 269 onDonationAmountChanged: function(event, helper) { 270 if (this.debugMode) { 271 console.log('[ConditionalLogic] Donation amount changed event received'); 272 } 273 274 // Get donation amount
275 var amount = 0; 276 if (helper && typeof helper.get_amount === 'function') { 277 amount = parseFloat(helper.get_amount()) || 0; 278 } else { 279 amount = this.getDonationAmount(); 280 } 281 282 if (this.debugMode) { 283 console.log('[ConditionalLogic] Donation amount:', amount); 284 } 285 286 // Update donation_amount field value for evaluation 287 this.updateFieldValue('donation_amount', amount); 288 289 if (this.debugMode) { 290 this.logDebugEvent('donation_amount_updated', { 291 amount: amount, 292 source: 'charitable:form:amount:changed', 293 timestamp: new Date().toISOString() 294 }); 295 } 296 297 // Re-evaluate all conditionals 298 this.evaluateAll(); 299 300 if (this.debugMode) { 301 this.printFormState('After donation amount change event'); 302 } 303 }, 304 305 /** 306 * Handle donation amount radio button change 307 */ 308 onDonationAmountRadioChange: function(event) { 309 if (this.debugMode) { 310 console.log('[ConditionalLogic] Donation amount radio changed:', $(event.target).val()); 311 } 312 313 var amount = this.getDonationAmount(); 314 if (this.debugMode) { 315 console.log('[ConditionalLogic] Donation amount from radio:', amount); 316 } 317 318 this.updateFieldValue('donation_amount', amount); 319 320 if (this.debugMode) { 321 this.logDebugEvent('donation_amount_updated', { 322 amount: amount, 323 source: 'radio_change', 324 timestamp: new Date().toISOString() 325 }); 326 } 327 328 this.evaluateAll(); 329 330 if (this.debugMode) { 331 this.printFormState('After donation amount radio change'); 332 } 333 }, 334 335 /** 336 * Handle custom donation amount input change 337 */ 338 onCustomAmountChange: function(event) { 339 if (this.debugMode) { 340 console.log('[ConditionalLogic] Custom donation amount changed'); 341 } 342 343 // Only process if custom radio is selected 344 var $customRadio = $('.charitable-visual-donation-form input[name="donation_amount"][value="custom"]'); 345 if ($customRadio.length && $customRadio.is(':checked')) { 346 var amount = this.getDonationAmount(); 347 if (this.debugMode) { 348 console.log('[ConditionalLogic] Custom donation amount:', amount); 349 } 350 351 this.updateFieldValue('donation_amount', amount); 352 353 if (this.debugMode) { 354 this.logDebugEvent('donation_amount_updated', { 355 amount: amount, 356 source: 'custom_input_change', 357 timestamp: new Date().toISOString() 358 }); 359 } 360 361 this.evaluateAll(); 362 363 if (this.debugMode) { 364 this.printFormState('After custom donation amount change'); 365 } 366 } 367 }, 368 369 /** 370 * Handle field change 371 */ 372 onFieldChange: function(event) { 373 var $field = $(event.target); 374 var fieldName = $field.attr('name'); 375 var fieldType = $field.attr('type'); 376 377 var fieldValue; 378 var actualFieldName = fieldName; 379 380 // Handle checkbox groups (name ends with []) 381 if (fieldType === 'checkbox' && fieldName && fieldName.indexOf('[]') !== -1) { 382 // Get all checkboxes in this group 383 var $allCheckboxes = $('.charitable-visual-donation-form input[type="checkbox"][name="' + fieldName + '"]'); 384 var checkedValues = []; 385 $allCheckboxes.each(function() { 386 if ($(this).is(':checked')) { 387 checkedValues.push($(this).val()); 388 } 389 }); 390 // Store with base name (without []) 391 actualFieldName = fieldName.replace(/\[\]$/, ''); 392 fieldValue = checkedValues; 393 if (this.debugMode) { 394 console.log('[ConditionalLogic] Checkbox group changed:', actualFieldName, '=', fieldValue); 395 } 396 this.updateFieldValue(actualFieldName, fieldValue); 397 } else { 398 // Regular field (single value) 399 fieldValue = this.getFieldValue($field); 400 401 if (this.debugMode) { 402 console.log('[ConditionalLogic] Field changed:', fieldName, '=', fieldValue); 403 } 404 405 // Update field value for evaluation 406 if (fieldName) { 407 this.updateFieldValue(fieldName, fieldValue); 408 } 409 } 410 411 if (this.debugMode) { 412 this.logDebugEvent('field_value_updated', { 413 fieldName: actualFieldName, 414 fieldValue: fieldValue, 415 timestamp: new Date().toISOString() 416 }); 417 } 418 419 // Re-evaluate all conditionals 420 this.evaluateAll(); 421 422 if (this.debugMode) { 423 this.printFormState('After field change: ' + fieldName); 424 } 425 }, 426 427 /** 428 * Get field value 429 */ 430 getFieldValue: function($field) { 431 var type = $field.attr('type'); 432 var tagName = $field.prop('tagName').toLowerCase(); 433 var fieldName = $field.attr('name'); 434 435 if (tagName === 'select') { 436 var val = $field.val(); 437 // Multi-select returns an array, single-select returns a string 438 return val || (Array.isArray(val) ? [] : ''); 439 } else if (tagName === 'textarea') { 440 return $field.val() || ''; 441 } else if (type === 'checkbox') { 442 // Check if this is part of a checkbox group (name ends with []) 443 if (fieldName && fieldName.indexOf('[]') !== -1) { 444 // Get all checkboxes with this name and return array of checked values 445 var baseName = fieldName.replace(/\[\]$/, ''); 446 var $allCheckboxes = $('.charitable-visual-donation-form input[type="checkbox"][name="' + fieldName + '"]'); 447 var checkedValues = []; 448 $allCheckboxes.each(function() { 449 if ($(this).is(':checked')) { 450 checkedValues.push($(this).val()); 451 } 452 }); 453 return checkedValues; 454 } else { 455 // Single checkbox 456 return $field.is(':checked') ? $field.val() : ''; 457 } 458 } else if (type === 'radio') { 459 return $field.is(':checked') ? $field.val() : ''; 460 } else { 461 return $field.val() || ''; 462 } 463 }, 464 465 /** 466 * Update field value in form data 467 */ 468 updateFieldValue: function(fieldName, value) { 469 // Store in a temporary data structure for evaluation 470 if (typeof this.fieldValues === 'undefined') { 471 this.fieldValues = {}; 472 } 473 this.fieldValues[fieldName] = value; 474 475 if (this.debugMode) { 476 console.log('[ConditionalLogic] Updated field value:', fieldName, '=', value); 477 } 478 }, 479 480 /** 481 * Read all field values from the DOM on initial load 482 * This ensures we have current values for all fields before evaluating conditionals 483 */ 484 readAllFieldValues: function() { 485 if (this.debugMode) { 486 console.log('[ConditionalLogic] Reading all field values from DOM'); 487 } 488 489 // Initialize fieldValues if needed 490 if (typeof this.fieldValues === 'undefined') { 491 this.fieldValues = {}; 492 } 493 494 // Read donation amount (special handling) 495 var donationAmount = this.getDonationAmount(); 496 this.updateFieldValue('donation_amount', donationAmount); 497 if (this.debugMode) { 498 console.log('[ConditionalLogic] Initial donation amount:', donationAmount); 499 500 // Debug: Show all donation amount inputs 501 var $allAmounts = $('.charitable-visual-donation-form input[name="donation_amount"]'); 502 console.log('[ConditionalLogic] All donation amount inputs:', $allAmounts.length); 503 $allAmounts.each(function() { 504 var $input = $(this); 505 console.log('[ConditionalLogic] - Value:', $input.val(), 'Checked:', $input.is(':checked'), 'ID:', $input.attr('id')); 506 }); 507 508 var $customAmount = $('.charitable-visual-donation-form input.custom-donation-input, .charitable-visual-donation-form input[name="custom_donation_amount"]'); 509 if ($customAmount.length) { 510 console.log('[ConditionalLogic] Custom amount input found, value:', $customAmount.val()); 511 } 512 } 513 514 // Read all other form fields 515 var $form = $('.charitable-visual-donation-form'); 516 if ($form.length) { 517 if (this.debugMode) { 518 console.log('[ConditionalLogic] Form found, scanning fields...'); 519 } 520 521 // Track which checkbox groups we've already processed 522 var processedCheckboxGroups = {}; 523 524 // Read all inputs, selects, and textareas 525 $form.find('input, select, textarea').each(function() { 526 var $field = $(this); 527 var fieldName = $field.attr('name'); 528 var fieldId = $field.attr('id'); 529 var fieldType = $field.attr('type'); 530 531 // Skip donation amount (already handled) 532 if (fieldName === 'donation_amount' || fieldName === 'custom_donation_amount' || fieldName === 'charitable_donation_amount') { 533 return; 534 } 535 536 // Skip hidden fields and system fields 537 if (fieldType === 'hidden' || 538 fieldName === '_charitable_donation_nonce' || 539 fieldName === '_wp_http_referer' || 540 fieldName === 'charitable_action' || 541 fieldName === 'campaign_id' || 542 fieldName === 'description' || 543 fieldName === 'ID' || 544 fieldName === 'gateway' || 545 fieldName === 'stripe_payment_method') { 546 return; 547 } 548 549 if (fieldName) { 550 // Handle checkbox groups (name ends with []) - only process once per group 551 if (fieldType === 'checkbox' && fieldName.indexOf('[]') !== -1) { 552 if (processedCheckboxGroups[fieldName]) { 553 return; // Already processed this checkbox group 554 } 555 processedCheckboxGroups[fieldName] = true; 556 // Get all checkboxes in this group 557 var $allCheckboxes = $form.find('input[type="checkbox"][name="' + fieldName + '"]'); 558 var checkedValues = []; 559 $allCheckboxes.each(function() { 560 if ($(this).is(':checked')) { 561 checkedValues.push($(this).val()); 562 } 563 }); 564 // Store with base name (without []) 565 var baseName = fieldName.replace(/\[\]$/, ''); 566 this.updateFieldValue(baseName, checkedValues); 567 if (this.debugMode) { 568 console.log('[ConditionalLogic] Read checkbox group value:', baseName, '=', checkedValues); 569 } 570 } else { 571 // Regular field (single value) 572 var value = this.getFieldValue($field); 573 this.updateFieldValue(fieldName, value); 574 575 if (this.debugMode) { 576 console.log('[ConditionalLogic] Read field value:', fieldName, '=', value, 'Type:', fieldType, 'ID:', fieldId); 577 } 578 } 579 } 580 }.bind(this)); 581 } else { 582 if (this.debugMode) { 583 console.warn('[ConditionalLogic] Form not found! Selector: .charitable-visual-donation-form'); 584 } 585 } 586 587 if (this.debugMode) { 588 console.log('[ConditionalLogic] Field values read:', Object.keys(this.fieldValues || {})); 589 console.log('[ConditionalLogic] Field values cache:', this.fieldValues); 590 } 591 }, 592 593 /** 594 * Get current field value 595 */ 596 getCurrentFieldValue: function(fieldId) { 597 // Map field ID to HTML format for consistent lookup 598 var mappedId = this.mapFieldIdToHtml ? this.mapFieldIdToHtml(fieldId) : fieldId; 599 600 if (this.debugMode && mappedId !== fieldId) { 601 console.log('[ConditionalLogic] Mapping field ID:', fieldId, '->', mappedId); 602 } 603
604 // Check stored values first (from readAllFieldValues or updates) 605 // Try both original and mapped IDs since values might be stored with either format 606 // Prefer mapped ID if it exists and has a value, otherwise try original ID 607 if (this.fieldValues) { 608 // First check mapped ID (more likely to have the actual value) 609 if (mappedId !== fieldId && this.fieldValues.hasOwnProperty(mappedId)) { 610 var mappedValue = this.fieldValues[mappedId]; 611 // Arrays are always valid (even if empty), so check for arrays first 612 if (Array.isArray(mappedValue)) { 613 if (this.debugMode) { 614 console.log('[ConditionalLogic] Found array in cache with mapped ID:', mappedId, '=', mappedValue); 615 } 616 return mappedValue; 617 } 618 // For non-arrays, only return if it has a value 619 if (mappedValue !== '' && mappedValue !== null && mappedValue !== undefined) { 620 if (this.debugMode) { 621 console.log('[ConditionalLogic] Found value in cache with mapped ID:', mappedId, '=', mappedValue); 622 } 623 return mappedValue; 624 } 625 } 626 // Then check original ID 627 if (this.fieldValues.hasOwnProperty(fieldId)) { 628 var originalValue = this.fieldValues[fieldId]; 629 // Arrays are always valid (even if empty) 630 if (Array.isArray(originalValue)) { 631 if (this.debugMode) { 632 console.log('[ConditionalLogic] Found array in cache with original ID:', fieldId, '=', originalValue); 633 } 634 return originalValue; 635 } 636 if (this.debugMode) { 637 console.log('[ConditionalLogic] Found value in cache with original ID:', fieldId, '=', originalValue); 638 } 639 // Only return if it has a value, otherwise continue to check mapped ID 640 if (originalValue !== '' && originalValue !== null && originalValue !== undefined) { 641 return originalValue; 642 } 643 } 644 // If mapped ID exists but was empty, return it anyway (empty is a valid value for strings) 645 if (mappedId !== fieldId && this.fieldValues.hasOwnProperty(mappedId)) { 646 if (this.debugMode) { 647 console.log('[ConditionalLogic] Found empty value in cache with mapped ID:', mappedId); 648 } 649 return this.fieldValues[mappedId]; 650 } 651 if (this.debugMode) { 652 console.log('[ConditionalLogic] Value not found in cache for:', fieldId, 'or', mappedId, 'Available keys:', Object.keys(this.fieldValues)); 653 } 654 } 655 656 // Special handling for donation_amount 657 if (fieldId === 'donation_amount' || fieldId === 'charitable_donation_amount') { 658 return this.getDonationAmount(); 659 } 660 661 // Try to find field by name or ID 662 var $field = $('.charitable-visual-donation-form [name="' + fieldId + '"], .charitable-visual-donation-form #charitable_field_' + fieldId + '_element, .charitable-visual-donation-form [data-field-id="' + fieldId + '"]'); 663 664 // Also try mapped field ID (for PHP field IDs like "6" -> "field_6") 665 if (!$field.length) { 666 $field = $('.charitable-visual-donation-form [name="' + mappedId + '"], .charitable-visual-donation-form #charitable_field_' + mappedId + '_element, .charitable-visual-donation-form #charitable_field_field_' + mappedId.replace('field_', '') + '_element'); 667 } 668 669 if ($field.length) { 670 var value = this.getFieldValue($field.first()); 671 // Cache it for future use 672 this.updateFieldValue(fieldId, value); 673 return value; 674 } 675 676 if (this.debugMode) { 677 console.warn('[ConditionalLogic] Field value not found for:', fieldId); 678 } 679 680 return ''; 681 }, 682 683 /** 684 * Evaluate all conditional rules 685 */ 686 evaluateAll: function() { 687 if (this.debugMode) { 688 console.log('[ConditionalLogic] Evaluating all conditionals'); 689 } 690 691 for (var formId in this.rules) { 692 if (this.rules.hasOwnProperty(formId)) { 693 this.evaluateForm(formId, this.rules[formId]); 694 } 695 } 696 }, 697 698 /** 699 * Evaluate conditional rules for a form 700 */ 701 evaluateForm: function(formId, formRules) { 702 if (this.debugMode) { 703 console.log('[ConditionalLogic] Evaluating form:', formId, 'Fields with rules:', Object.keys(formRules)); 704 } 705 706 for (var fieldId in formRules) { 707 if (formRules.hasOwnProperty(fieldId)) { 708 if (this.debugMode) { 709 console.log('[ConditionalLogic] Processing field:', fieldId, 'Rules:', formRules[fieldId]); 710 } 711 this.evaluateField(fieldId, formRules[fieldId]); 712 } 713 } 714 }, 715 716 /** 717 * Evaluate conditional rules for a field 718 */ 719 evaluateField: function(fieldId, fieldRules) { 720 if (!fieldRules || !fieldRules.conditionals) { 721 if (this.debugMode) { 722 console.warn('[ConditionalLogic] Field', fieldId, 'has no conditionals or invalid rules:', fieldRules); 723 } 724 return; 725 } 726 727 var conditionalType = fieldRules.conditional_type || 'show'; 728 var conditionals = fieldRules.conditionals; 729 730 if (this.debugMode) { 731 console.log('[ConditionalLogic] ===== Evaluating field:', fieldId, '====='); 732 console.log('[ConditionalLogic] Conditional type:', conditionalType); 733 console.log('[ConditionalLogic] Conditionals structure:', conditionals); 734 console.log('[ConditionalLogic] Number of groups:', Object.keys(conditionals).length); 735 } 736 737 // Evaluate groups (OR logic - any group passing means field should be shown) 738 var shouldShow = false;
739 var hasValidGroups = false; 740 741 for (var groupId in conditionals) { 742 if (conditionals.hasOwnProperty(groupId)) { 743 var group = conditionals[groupId]; 744 745 // A group only reaches us as a JS array when its rule keys are the 746 // contiguous sequence 0..n-1. PHP sends an object for any other key 747 // set, and the Array.isArray() check below then skipped the group 748 // whole - so a "show" field whose only group was skipped stayed 749 // hidden no matter what its controlling field did. 750 // 751 // clear_empty_rules() reindexes before output as of 1.8.18, so this 752 // is the belt to that braces: it also covers a page still serving a 753 // cached copy of an older payload, and any filter on 754 // charitable_conditional_logic_generate_rules that rebuilds groups. 755 if (group && typeof group === 'object' && !Array.isArray(group)) { 756 var rulesByKey = group; 757 group = Object.keys(rulesByKey).map(function (ruleId) { 758 return rulesByKey[ruleId]; 759 }); 760 } 761 762 // Skip empty groups 763 if (!Array.isArray(group) || group.length === 0) { 764 continue; 765 } 766 767 hasValidGroups = true; 768 769 if (this.debugMode) { 770 console.log('[ConditionalLogic] Evaluating group', groupId, 'with', group.length, 'rules'); 771 } 772 773 var groupPasses = this.evaluateGroup(group); 774 775 if (this.debugMode) { 776 console.log('[ConditionalLogic] Group', groupId, 'passes:', groupPasses); 777 } 778 779 if (groupPasses) { 780 shouldShow = true; 781 if (this.debugMode) { 782 console.log('[ConditionalLogic] Group', groupId, 'passed, field should be shown (OR logic)'); 783 } 784 break; // OR logic - one passing group is enough 785 } 786 } 787 } 788 789 // Handle conditional_type logic 790 if (conditionalType === 'hide') { 791 // Hide: Hide when condition is met, show otherwise 792 shouldShow = !shouldShow; 793 if (this.debugMode) { 794 console.log('[ConditionalLogic] Conditional type is "hide", inverting result:', shouldShow); 795 } 796 } else { 797 // Show (default): Show when condition is met, hide otherwise 798 // No special handling needed - if condition fails, field is hidden 799 if (this.debugMode && !shouldShow && hasValidGroups) { 800 console.log('[ConditionalLogic] Condition failed with "show" type, field will be hidden'); 801 } 802 } 803 804 if (this.debugMode) { 805 console.log('[ConditionalLogic] Final decision for field', fieldId, '- should show:', shouldShow); 806 } 807 808 // Show or hide the field 809 this.toggleField(fieldId, shouldShow); 810 811 // Debug: Log the decision 812 if (this.debugMode) { 813 this.logDebugEvent('field_visibility_decision', { 814 fieldId: fieldId, 815 shouldShow: shouldShow, 816 conditionalType: conditionalType, 817 fieldValue: this.getCurrentFieldValue(fieldId), 818 timestamp: new Date().toISOString() 819 }); 820 } 821 }, 822 823 /** 824 * Evaluate a group of rules (AND logic - all rules must pass) 825 */ 826 evaluateGroup: function(group) { 827 if (!Array.isArray(group) || group.length === 0) { 828 return false; 829 } 830 831 for (var i = 0; i < group.length; i++) { 832 var rule = group[i]; 833 if (!this.evaluateRule(rule)) { 834 return false; // AND logic - all rules must pass 835 } 836 } 837 838 return true; 839 }, 840 841 /** 842 * Evaluate a single rule 843 */ 844 evaluateRule: function(rule) { 845 if (!rule || !rule.field || !rule.operator) { 846 if (this.debugMode) { 847 console.warn('[ConditionalLogic] Invalid rule:', rule); 848 } 849 return false; 850 } 851 852 var fieldValue = this.getCurrentFieldValue(rule.field); 853 var operator = rule.operator; 854 var ruleValue = rule.value || ''; 855 856 if (this.debugMode) { 857 console.log('[ConditionalLogic] --- Evaluating rule ---'); 858 console.log('[ConditionalLogic] Trigger field:', rule.field); 859 console.log('[ConditionalLogic] Operator:', operator); 860 console.log('[ConditionalLogic] Rule value:', ruleValue); 861 console.log('[ConditionalLogic] Actual field value:', fieldValue); 862 console.log('[ConditionalLogic] Value type:', typeof fieldValue); 863 console.log('[ConditionalLogic] Is array?', Array.isArray(fieldValue)); 864 } 865 866 // Handle arrays (from checkboxes or multi-select) 867 var isArray = Array.isArray(fieldValue); 868 869 // Convert to appropriate types for comparison 870 var isNumeric = !isArray && !isNaN(parseFloat(fieldValue)) && isFinite(fieldValue); 871 var fieldValueNum = isNumeric ? parseFloat(fieldValue) : 0; 872 var ruleValueNum = !isNaN(parseFloat(ruleValue)) ? parseFloat(ruleValue) : 0; 873 874 var fieldValueStr = isArray ? '' : String(fieldValue).toLowerCase().trim(); 875 var ruleValueStr = String(ruleValue).toLowerCase().trim(); 876 877 if (this.debugMode) { 878 console.log('[ConditionalLogic] Numeric comparison?', isNumeric); 879 if (isNumeric) { 880 console.log('[ConditionalLogic] Numeric values - Field:', fieldValueNum, 'Rule:', ruleValueNum); 881 } else { 882 console.log('[ConditionalLogic] String values - Field:', fieldValueStr, 'Rule:', ruleValueStr); 883 } 884 } 885 886 var result = false;
887 switch (operator) { 888 case '==': 889 if (isArray) { 890 // For arrays, check if rule value is in the array 891 result = fieldValue.indexOf(ruleValue) !== -1 || fieldValue.indexOf(ruleValueStr) !== -1; 892 } else { 893 result = fieldValueStr === ruleValueStr; 894 } 895 break; 896 case '!=': 897 if (isArray) { 898 // For arrays, check if rule value is NOT in the array 899 result = fieldValue.indexOf(ruleValue) === -1 && fieldValue.indexOf(ruleValueStr) === -1; 900 } else { 901 result = fieldValueStr !== ruleValueStr; 902 } 903 break; 904 case '>': 905 result = isNumeric && fieldValueNum > ruleValueNum; 906 break; 907 case '>=': 908 result = isNumeric && fieldValueNum >= ruleValueNum; 909 break; 910 case '<': 911 result = isNumeric && fieldValueNum < ruleValueNum; 912 break; 913 case '<=': 914 result = isNumeric && fieldValueNum <= ruleValueNum; 915 break; 916 case 'contains': 917 if (isArray) { 918 // For arrays, check if any element contains the rule value 919 result = fieldValue.some(function(val) { 920 return String(val).toLowerCase().indexOf(ruleValueStr) !== -1; 921 }); 922 } else { 923 result = fieldValueStr.indexOf(ruleValueStr) !== -1; 924 } 925 break; 926 case 'starts_with': 927 if (isArray) { 928 // For arrays, check if any element starts with the rule value 929 result = fieldValue.some(function(val) { 930 return String(val).toLowerCase().indexOf(ruleValueStr) === 0; 931 }); 932 } else { 933 result = fieldValueStr.indexOf(ruleValueStr) === 0; 934 } 935 break; 936 case 'ends_with': 937 if (isArray) { 938 // For arrays, check if any element ends with the rule value 939 result = fieldValue.some(function(val) { 940 var valStr = String(val).toLowerCase(); 941 return valStr.indexOf(ruleValueStr) === valStr.length - ruleValueStr.length; 942 }); 943 } else { 944 result = fieldValueStr.indexOf(ruleValueStr) === fieldValueStr.length - ruleValueStr.length; 945 } 946 break; 947 case 'e': 948 case 'empty': 949 if (isArray) { 950 result = fieldValue.length === 0; 951 } else { 952 result = !fieldValue || fieldValueStr === ''; 953 } 954 break; 955 case '!e': 956 case '!empty': 957 if (isArray) { 958 result = fieldValue.length > 0; 959 } else { 960 result = fieldValue && fieldValueStr !== ''; 961 } 962 break; 963 default: 964 if (this.debugMode) { 965 console.warn('[ConditionalLogic] Unknown operator:', operator); 966 } 967 result = false;
968 } 969 970 if (this.debugMode) { 971 console.log('[ConditionalLogic] Rule result:', result); 972 } 973 974 return result; 975 }, 976 977 /** 978 * Map field ID from conditional logic format to HTML format 979 * Conditional logic uses PHP field IDs (like "6") or sequential IDs (like "field_6") 980 * HTML uses field names like "field_6" or IDs like "charitable_field_field_6" 981 */ 982 mapFieldIdToHtml: function(fieldId) { 983 // If already in field_X format, use as is 984 if (typeof fieldId === 'string' && fieldId.indexOf('field_') === 0) { 985 return fieldId; 986 } 987 988 // If numeric, convert to field_X format 989 var numericId = parseInt(fieldId, 10); 990 if (!isNaN(numericId)) { 991 return 'field_' + numericId; 992 } 993 994 // Return as is if can't determine 995 return fieldId; 996 }, 997 998 /** 999 * Toggle field visibility 1000 */ 1001 toggleField: function(fieldId, show) { 1002 // Map field ID to HTML format 1003 var mappedFieldId = this.mapFieldIdToHtml(fieldId); 1004 var numericId = typeof fieldId === 'string' && fieldId.indexOf('field_') === 0 ? fieldId.replace('field_', '') : fieldId; 1005 1006 if (this.debugMode) { 1007 console.log('[ConditionalLogic] Toggling field - Original ID:', fieldId, 'Mapped ID:', mappedFieldId, 'Numeric ID:', numericId); 1008 } 1009 1010 // Try multiple selectors to find the field 1011 var selectors = [ 1012 // Try by data attributes 1013 '[data-field-id="' + fieldId + '"]', 1014 '[data-field-id="' + mappedFieldId + '"]', 1015 '[data-field-id="' + numericId + '"]', 1016 '[data-charitable-field-id="' + fieldId + '"]', 1017 '[data-charitable-field-id="' + mappedFieldId + '"]', 1018 // Try by input name 1019 'input[name="' + mappedFieldId + '"]', 1020 'input[name="field_' + numericId + '"]', 1021 // Try by element ID 1022 '#charitable_field_' + mappedFieldId + '_element', 1023 '#charitable_field_field_' + numericId + '_element', 1024 '#charitable-form-field-' + numericId, 1025 // Try by fieldset containing field with this name 1026 '.charitable-fieldset:has(input[name="' + mappedFieldId + '"])', 1027 '.charitable-fieldset:has(#charitable_field_' + mappedFieldId + '_element)', 1028 '.charitable-fieldset:has(#charitable_field_field_' + numericId + '_element)' 1029 ]; 1030 1031 var $field = null; 1032 for (var i = 0; i < selectors.length; i++) { 1033 try { 1034 var $found = $(selectors[i]); 1035 if ($found.length) { 1036 // If we found an input, get its parent fieldset 1037 if ($found.is('input, select, textarea')) { 1038 $field = $found.closest('.charitable-fieldset'); 1039 if (!$field.length) { 1040 // Fallback: get parent div with charitable-form-field class 1041 $field = $found.closest('.charitable-form-field').closest('.charitable-fieldset'); 1042 if (!$field.length) { 1043 // Last fallback: get the input's parent container 1044 $field = $found.closest('fieldset, .charitable-fieldset, .charitable-form-field'); 1045 } 1046 } 1047 } else { 1048 // Already a fieldset or container 1049 $field = $found; 1050 } 1051 if ($field.length) { 1052 if (this.debugMode) { 1053 console.log('[ConditionalLogic] Field found using selector:', selectors[i]); 1054 } 1055 break; 1056 } 1057 } 1058 } catch (e) { 1059 // Some selectors like :has() might not work in older browsers, skip 1060 if (this.debugMode) { 1061 console.warn('[ConditionalLogic] Selector failed:', selectors[i], e); 1062 } 1063 } 1064 } 1065 1066 // If still not found, try to find by searching all fieldsets for the input 1067 if (!$field || !$field.length) { 1068 var $allInputs = $('.charitable-visual-donation-form input[name="' + mappedFieldId + '"], .charitable-visual-donation-form #charitable_field_' + mappedFieldId + '_element, .charitable-visual-donation-form #charitable_field_field_' + numericId + '_element'); 1069 if ($allInputs.length) { 1070 $field = $allInputs.first().closest('.charitable-fieldset'); 1071 if (!$field.length) { 1072 $field = $allInputs.first().closest('.charitable-form-field').parent(); 1073 } 1074 } 1075 } 1076 1077 if (!$field || !$field.length) { 1078 if (this.debugMode) { 1079 console.warn('[ConditionalLogic] Field not found:', fieldId, 'Mapped:', mappedFieldId, 'Selectors tried:', selectors.length); 1080 console.warn('[ConditionalLogic] Available fields:', $('.charitable-visual-donation-form .charitable-fieldset').map(function() { 1081 var $fs = $(this); 1082 var name = $fs.find('input, select, textarea').first().attr('name'); 1083 var id = $fs.find('input, select, textarea').first().attr('id'); 1084 return name || id || 'unknown'; 1085 }).get()); 1086 } 1087 return; 1088 } 1089 1090 if (this.debugMode) { 1091 console.log('[ConditionalLogic] Toggling field:', fieldId, 'Show:', show, 'Element:', $field, 'Element classes:', $field.attr('class')); 1092 } 1093 1094 // Check if field was pending (initially hidden to prevent flash) 1095 var wasPending = $field.hasClass('charitable-conditional-field-pending'); 1096 1097 // Remove pending class - field has been evaluated, no longer need to hide it initially 1098 $field.removeClass('charitable-conditional-field-pending'); 1099 1100 // Show or hide with animation 1101 if (show) { 1102 $field.stop(true, true).fadeIn(300); 1103 $field.removeClass('charitable-conditional-hidden'); 1104 // Re-enable required if field is shown and was originally required 1105 // Check both data attribute (stored when hidden) and field's required-field class 1106 $field.find('input, select, textarea').each(function() { 1107 var $input = $(this);
1108 // Check if field was originally required (stored in data attribute when hidden) 1109 if ($input.attr('data-original-required') === 'true' || $input.data('original-required') === true) { 1110 $input.prop('required', true); 1111 } 1112 // Also check if field wrapper has required-field class (indicates field is required) 1113 else if ($field.hasClass('required-field')) { 1114 $input.prop('required', true); 1115 } 1116 }); 1117 } else { 1118 // Store original required state before hiding 1119 $field.find('input, select, textarea').each(function() { 1120 var $input = $(this); 1121 // Store original required state if not already stored 1122 if (!$(this).data('original-required-set')) { 1123 var wasRequired = $input.prop('required') || $input.is('[required]'); 1124 if (wasRequired) { 1125 $input.attr('data-original-required', 'true'); 1126 $input.data('original-required', true); 1127 } else { 1128 $input.attr('data-original-required', 'false'); 1129 $input.data('original-required', false); 1130 } 1131 $input.data('original-required-set', true); 1132 } 1133 }); 1134 // If field was pending, hide immediately without animation to prevent flash 1135 if (wasPending) { 1136 $field.hide(); 1137 } else { 1138 $field.stop(true, true).fadeOut(300); 1139 } 1140 $field.addClass('charitable-conditional-hidden'); 1141 // Remove required attribute if hidden (HTML5 validation should not apply to hidden fields) 1142 $field.find('input, select, textarea').prop('required', false); 1143 } 1144 }, 1145 1146 /** 1147 * Setup comprehensive debug monitoring 1148 */ 1149 setupDebugMonitoring: function() { 1150 var self = this; 1151 1152 // Monitor all clicks inside the form 1153 $(document).on('click', '.charitable-visual-donation-form', function(e) { 1154 var $target = $(e.target); 1155 var tagName = $target.prop('tagName'); 1156 var type = $target.attr('type'); 1157 var name = $target.attr('name'); 1158 var value = $target.attr('value') || $target.val(); 1159 var id = $target.attr('id'); 1160 1161 var eventData = { 1162 event: 'click', 1163 tag: tagName, 1164 type: type, 1165 name: name, 1166 value: value, 1167 id: id, 1168 classes: $target.attr('class'), 1169 timestamp: new Date().toISOString() 1170 }; 1171 1172 self.logDebugEvent('user_interaction', eventData); 1173 1174 // Special handling for donation amount radios 1175 if (name === 'donation_amount') { 1176 setTimeout(function() { 1177 var checked = $('.charitable-visual-donation-form input[name="donation_amount"]:checked'); 1178 var checkedValue = checked.length ? checked.val() : 'none'; 1179 var amount = self.getDonationAmount(); 1180 1181 self.logDebugEvent('donation_amount_selected', { 1182 selectedValue: checkedValue, 1183 calculatedAmount: amount, 1184 timestamp: new Date().toISOString() 1185 }); 1186 1187 // Show current form state 1188 self.printFormState('After donation amount selection'); 1189 }, 50); 1190 } 1191 }); 1192 1193 // Monitor all change events 1194 $(document).on('change input', '.charitable-visual-donation-form *', function(e) { 1195 var $target = $(e.target); 1196 var fieldName = $target.attr('name'); 1197 var oldValue = self.fieldValueHistory[fieldName] || ''; 1198 var newValue = $target.val() || ''; 1199 1200 if (oldValue !== newValue) { 1201 self.logDebugEvent('field_value_changed', { 1202 fieldName: fieldName, 1203 oldValue: oldValue, 1204 newValue: newValue, 1205 type: $target.attr('type'), 1206 tag: $target.prop('tagName'), 1207 id: $target.attr('id'), 1208 timestamp: new Date().toISOString() 1209 }); 1210 1211 // Update history 1212 self.fieldValueHistory[fieldName] = newValue; 1213 } 1214 }); 1215 1216 // Expose debug functions globally 1217 window.dumpConditionalLogicState = function() { 1218 self.printFormState('Manual dump'); 1219 }; 1220 1221 window.getConditionalLogicHistory = function() { 1222 return self.debugHistory; 1223 }; 1224 1225 window.clearConditionalLogicHistory = function() { 1226 self.debugHistory = []; 1227 self.fieldValueHistory = {}; 1228 console.log('[ConditionalLogic] Debug history cleared'); 1229 }; 1230 }, 1231 1232 /** 1233 * Log a debug event 1234 */ 1235 logDebugEvent: function(eventType, data) { 1236 if (!this.debugMode) { 1237 return; 1238 } 1239 1240 var event = { 1241 type: eventType, 1242 data: data, 1243 timestamp: new Date().toISOString() 1244 }; 1245 1246 this.debugHistory.push(event); 1247 1248 // Keep history to last 100 events 1249 if (this.debugHistory.length > 100) { 1250 this.debugHistory.shift(); 1251 } 1252 1253 // Console output with structured format 1254 console.log('[ConditionalLogic] [' + eventType + ']', data); 1255 }, 1256 1257 /** 1258 * Print current form state for debugging 1259 */ 1260 printFormState: function(context) { 1261 if (!this.debugMode) { 1262 return; 1263 } 1264 1265 console.group('[ConditionalLogic] ===== FORM STATE: ' + (context || 'Current') + ' ====='); 1266 1267 // Current field values 1268 console.group('ð Current Field Values:'); 1269 var fieldValues = {}; 1270 for (var fieldName in this.fieldValues) { 1271 if (this.fieldValues.hasOwnProperty(fieldName)) { 1272 fieldValues[fieldName] = this.fieldValues[fieldName]; 1273 } 1274 } 1275 // Also get donation amount
1276 fieldValues['donation_amount'] = this.getDonationAmount(); 1277 console.table(fieldValues); 1278 console.groupEnd(); 1279 1280 // Conditional logic rules 1281 console.group('ð Conditional Logic Rules:'); 1282 for (var formId in this.rules) { 1283 if (this.rules.hasOwnProperty(formId)) { 1284 console.group('Form: ' + formId); 1285 for (var fieldId in this.rules[formId]) { 1286 if (this.rules[formId].hasOwnProperty(fieldId)) { 1287 var fieldRules = this.rules[formId][fieldId]; 1288 console.group('Field ' + fieldId + ' (' + (fieldRules.conditional_type || 'show') + '):'); 1289 console.log('Rules:', fieldRules); 1290 1291 // Show current value for trigger field 1292 if (fieldRules.conditionals) { 1293 for (var groupId in fieldRules.conditionals) { 1294 if (fieldRules.conditionals.hasOwnProperty(groupId)) { 1295 var group = fieldRules.conditionals[groupId]; 1296 console.group('Group ' + groupId + ':'); 1297 for (var i = 0; i < group.length; i++) { 1298 var rule = group[i]; 1299 var triggerValue = this.getCurrentFieldValue(rule.field); 1300 console.log('Rule ' + (i + 1) + ':', { 1301 triggerField: rule.field, 1302 triggerValue: triggerValue, 1303 operator: rule.operator, 1304 compareValue: rule.value, 1305 passes: this.evaluateRule(rule) 1306 }); 1307 } 1308 console.groupEnd(); 1309 } 1310 } 1311 } 1312 console.groupEnd(); 1313 } 1314 } 1315 console.groupEnd(); 1316 } 1317 } 1318 console.groupEnd(); 1319 1320 // Field visibility status 1321 console.group('ðï¸ Field Visibility Status:'); 1322 for (var formId in this.rules) { 1323 if (this.rules.hasOwnProperty(formId)) { 1324 for (var fieldId in this.rules[formId]) { 1325 if (this.rules[formId].hasOwnProperty(fieldId)) { 1326 var $field = this.findFieldElement(fieldId); 1327 var isVisible = $field && $field.length && $field.is(':visible'); 1328 console.log('Field ' + fieldId + ':', isVisible ? 'â VISIBLE' : 'â HIDDEN'); 1329 } 1330 } 1331 } 1332 } 1333 console.groupEnd(); 1334 1335 // Recent debug events (last 10) 1336 if (this.debugHistory.length > 0) { 1337 console.group('ð Recent Events (last 10):'); 1338 var recentEvents = this.debugHistory.slice(-10); 1339 recentEvents.forEach(function(event, index) { 1340 console.log('[' + (index + 1) + ']', event.type, 'at', event.timestamp, ':', event.data); 1341 }); 1342 console.groupEnd(); 1343 } 1344 1345 console.groupEnd(); 1346 }, 1347 1348 /** 1349 * Find field element by ID (helper for debugging) 1350 */ 1351 findFieldElement: function(fieldId) { 1352 var mappedFieldId = this.mapFieldIdToHtml(fieldId); 1353 var numericId = typeof fieldId === 'string' && fieldId.indexOf('field_') === 0 ? fieldId.replace('field_', '') : fieldId; 1354 1355 var selectors = [ 1356 '[data-field-id="' + fieldId + '"]', 1357 '[data-field-id="' + mappedFieldId + '"]', 1358 '[data-charitable-field-id="' + fieldId + '"]', 1359 'input[name="' + mappedFieldId + '"]', 1360 '#charitable_field_' + mappedFieldId + '_element' 1361 ]; 1362 1363 for (var i = 0; i < selectors.length; i++) { 1364 var $found = $(selectors[i]); 1365 if ($found.length) { 1366 if ($found.is('input, select, textarea')) { 1367 return $found.closest('.charitable-fieldset'); 1368 } 1369 return $found; 1370 } 1371 } 1372 1373 return $(); 1374 } 1375 }; 1376 1377 // Initialize on load 1378 CharitableConditionalLogic.init(); 1379 1380 // Expose globally for debugging 1381 window.CharitableConditionalLogic = CharitableConditionalLogic; 1382 1383})(jQuery); 1384
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.