1(function (win) { 2 /* eslint no-shadow: ["error"] */ 3 function FundingAllocator(config) { 4 this.config = config || {}; 5 this.$el = $(config.selector || "#funding-allocator"); 6 this.pollId = config.pollId || this.$el.data("poll-id"); 7 window.TOOLS && 8 TOOLS.requireCSSFile("/Content/PollCSS/funding-allocator.css"); 9 window.TOOLS && 10 TOOLS.requireCSSFile( 11 "https://cdnjs.cloudflare.com/ajax/libs/noUiSlider/11.0.3/nouislider.css" 12 ); 13 window.TOOLS && TOOLS.requireCSSFile("/Content/PollCSS/slider.css"); 14 15 // Load BootstrapToast script 16 if ( 17 window.ScriptLoader && 18 ScriptLoader.scriptArrays && 19 ScriptLoader.scriptArrays.bootstrapToast 20 ) { 21 ScriptLoader.loadScriptArray(ScriptLoader.scriptArrays.bootstrapToast); 22 } 23 this.model = {}; 24 this.total = 0; 25 // Read layout prefs from data attributes (set server-side) 26 this.gridCols = 2; 27 this.imageAspect = "4:3"; 28 29 // contact form field definitions 30 this.contactFields = []; 31 32 // Show loading spinner 33 if (window.TOOLS && TOOLS.showLoadingSpinner) { 34 this.$el.html(TOOLS.showLoadingSpinner()); 35 } else { 36 this.$el.html( 37 '<div class="text-center p-4"><div class="spinner-border" role="status"><span class="sr-only">Loading...</span></div></div>' 38 ); 39 } 40 41 this.loadData(); 42 43 // eslint-disable-next-line no-shadow 44 var self = this; 45 46 this.$el.on("input change", ".fa-input", function () { 47 var $num = $(this); 48 var val = parseFloat($num.val()) || 0; 49 50 // Only enforce that values are not negative 51 if (val < 0) { 52 val = 0; 53 } 54 55 $num.val(val); 56 var slider = $num.data("slider"); 57 if (slider) { 58 slider.noUiSlider.set(val); 59 } 60 self.updateRemaining(); 61 }); 62 this.$el.on("click", ".fa-submit", function () { 63 self.submit(); 64 }); 65 // Edit responses handler (added dynamically in success message) 66 this.$el.on("click", ".fa-edit-responses", function () { 67 self.enableEditing(); 68 }); 69 this.$el.on("click", ".fa-reset", function () { 70 self.resetAllocations(); 71 }); 72 73 this.isSubmitted = false; // track submission state 74 this.budgetLimitNotified = false; 75 76 /* 77 * Helper: mark the parent poll as having a response so higher-level 78 * survey logic (end-zone, step survey, etc.) treats it as completed. 79 */ 80 this._markPollAsVoted = function () { 81 var $pollMeta = $('.poll-meta[data-pollid="' + self.pollId + '"]'); 82 if ($pollMeta.length) { 83 var meta = $pollMeta.data() || {}; 84 meta.hasvoted = true; 85 $pollMeta.data(meta); 86 // Also update inline attribute for CSS/legacy selectors 87 $pollMeta.attr("data-hasvoted", "true"); 88 } 89 }; 90 } 91 92 FundingAllocator.prototype.loadData = function () { 93 // eslint-disable-next-line no-shadow 94 var self = this; 95 $.getJSON("/Poll/GetFundingAllocator", { pollId: this.pollId }) 96 .done(function (data) { 97 self.model = data || {}; 98 self.contactFields = self.model.contactFields || []; 99 var pollObj = self.model.poll || {}; 100 var ext = pollObj.extendedOptions || {}; 101 self.total = parseFloat(ext.budget) || 0; 102 self.minimumSpend = parseFloat(ext.minimumSpend) || 0; 103 var parsedCols = parseInt( 104 ext.FundingAllocatorCols || ext.fundingAllocatorCols 105 ); 106 // eslint-disable-next-line 107 self.gridCols = [1, 2, 3, 4].includes(parsedCols) ? parsedCols : 2; 108 self.imageAspect = (ext.fundingAllocatorAspect || "4:3").toString(); 109 self.redirectUrl = pollObj.RedirectUrl || pollObj.redirectUrl || ""; 110 self.randomizeOrder = 111 pollObj.RandomizeOrder || pollObj.randomizeOrder || false; 112 113 // Track closed status to hide edit button if poll closed 114 // eslint-disable-next-line 115 self.pollClosed = pollObj.closed || false; 116 117 self.renderOptions(); 118 self.applyInitialState(); 119 }) 120 .fail(function () { 121 self.$el.html( 122 '<div class="alert alert-danger text-center">Error loading funding allocator</div>' 123 ); 124 }); 125 }; 126 127 // Determine initial UI state based on poll status & prior responses 128 FundingAllocator.prototype.applyInitialState = function () { 129 const hasResponses = 130 this.model.pollResponses && this.model.pollResponses.length > 0;
131 this.isSubmitted = hasResponses; 132 if (this.isSubmitted) { 133 this._markPollAsVoted(); 134 } 135 136 if (this.pollClosed) { 137 this.disableInputs(); 138 139 if (hasResponses) { 140 this.showSuccessMessage(); // success banner w/o edit button 141 } else { 142 this.$el.find(".fa-submit").addClass("d-none"); // hide submit button 143 } 144 return; 145 } 146 147 if (hasResponses) { 148 this.disableInputs(); 149 this.showSuccessMessage(); // success banner with edit button 150 return; 151 } 152 153 // open poll, no response yet 154 this.enableInputs(); 155 this.updateRemaining(); 156 }; 157 158 FundingAllocator.prototype.renderOptions = function () { 159 var html = ""; 160 161 // Budget banner (only show if budget is set) 162 if (this.total > 0) { 163 html += ` 164 <div class="bg-light rounded p-4 mb-4 shadow-sm" role="region" aria-labelledby="fa_remaining_budget_heading"> 165 <div class="fa-remaining-wrapper text-center mb-3"> 166 <h4 id="fa_remaining_budget_heading" class="text-dark mb-1">Your remaining budget:</h4> 167 <h2 class="fa-remaining-value text-dark fw-bold display-4" aria-live="polite"><span></span></h2> 168 </div> 169 <div class="position-relative" aria-hidden="true"> 170 <div class="progress" style="height:6px;"> 171 <div class="progress-bar fa-progress-bar bg-primary" style="width:0%"></div> 172 </div>`; 173 174 if (this.minimumSpend > 0 && this.total > 0) { 175 var perc = (this.minimumSpend / this.total) * 100; 176 html += ` 177 <div class="fa-minimum-marker" style="left:${perc}%"> 178 <div class="fa-minimum-tooltip">Minimum Spend<br>$${this.minimumSpend.toLocaleString( 179 "en-US" 180 )}</div> 181 </div>`; 182 } 183 184 html += ` 185 </div> 186 </div>`; 187 } 188 189 // Grid of option cards 190 var rowClass = `row row-cols-1 row-cols-md-${this.gridCols} g-4 mt-3`; 191 html += `<div class="${rowClass}" role="region" aria-labelledby="fa_options_heading"> 192 <h3 id="fa_options_heading" class="visually-hidden">Funding Options</h3>`; 193 194 var opts = this.model.pollOptions || []; 195 196 if (this.randomizeOrder) { 197 for (let i = opts.length - 1; i > 0; i--) { 198 var j = Math.floor(Math.random() * (i + 1)); 199 [opts[i], opts[j]] = [opts[j], opts[i]]; 200 } 201 } 202 203 for (let i = 0; i < opts.length; i++) { 204 var opt = opts[i]; 205 var optionId = opt.id; 206 207 var respArr = this.model.pollResponses || []; 208 var resp = respArr.find((r) => r.optionId === optionId); 209 var val = resp ? resp.allocatedAmount || 0 : 0; 210 211 var bOpts = this.model.budgetOptions || []; 212 var bOpt = bOpts.find((b) => b.optionId === optionId); 213 var max = 0; 214 215 if (bOpt) { 216 max = parseFloat(bOpt.cost || bOpt.amount || 0); 217 } 218 if (!max || isNaN(max)) { 219 max = parseFloat(opt.cost || 0); 220 } 221 if (!max || isNaN(max)) { 222 max = this.total || 1000000; 223 } // Use high default if no budget set 224 225 var title = opt.name || "Option"; 226 var desc = (opt.description || "").trim(); 227 var images = this.model.optionImages || {}; 228 var imgObj = images[optionId] || images[optionId.toString()]; 229 var imgUrl = imgObj ? imgObj.url : null; 230 var imgAlt = imgObj ? imgObj.altText || title : title; 231 232 html += ` 233 <div class="col d-flex"> 234 <div class="card h-100 fa-card border border-light mb-4" data-option-id="${optionId}" data-max="${max}" role="group" aria-labelledby="option_title_${optionId}"> 235 <div class="fa-funded-header bg-success text-white px-2 py-1 d-none">Funded</div>`; 236 237 if (imgUrl) { 238 var pad = "75%"; 239 switch (this.imageAspect) { 240 case "1:1": 241 pad = "100%"; 242 break; 243 case "16:9": 244 pad = "56.25%"; 245 break; 246 default: 247 // do nothing 248 break; 249 } 250 html += ` 251 <div class="fa-img card-img-top" style="background-image:url('${imgUrl}');padding-top:${pad};" role="img" aria-label="${imgAlt}"></div>`; 252 } 253 254 html += ` 255 <div class="card-body d-flex flex-column"> 256 <h5 id="option_title_${optionId}" class="card-title">${title}</h5>`; 257 258 if (desc) { 259 html += `<p class="card-text text-muted">${desc}</p>`; 260 } 261 262 html += ` 263 <div class="fa-slider mb-2" role="slider" tabindex="0" 264 aria-valuemin="0" aria-valuemax="${max}" aria-valuenow="${val}"
265 aria-valuetext="$${val.toLocaleString( 266 "en-US" 267 )} of $${max.toLocaleString("en-US")}" 268 aria-label="Allocated amount for ${title}" 269 data-base-max="${max}" data-value="${val}" data-step="1000"></div> 270 271 <div class="d-flex justify-content-between text-muted mb-2 fa-slider-values" aria-hidden="true"> 272 <span>$0</span><span class="fa-current">$${val.toLocaleString( 273 "en-US" 274 )}</span><span>$${max.toLocaleString("en-US")}</span> 275 </div> 276 277 <input type="number" class="fa-input form-control form-control-sm mt-auto d-none" value="${val}" min="0" max="${max}" step="1000" /> 278 </div> 279 </div> 280 </div>`; 281 } 282 283 html += `</div>`; 284 285 // Contact / footer 286 html += ` 287 <div class="fa-footer-outline mt-4 border border-primary border-2 rounded p-4" role="region" aria-labelledby="fa_contact_heading">`; 288 289 if (this.contactFields && this.contactFields.length) { 290 html += `<h3 id="fa_contact_heading" class="visually-hidden">Contact Information</h3> 291 <form class="fa-contact-form" novalidate> 292 <div class="row g-3">`; 293 294 for (var ci = 0; ci < this.contactFields.length; ci++) { 295 var f = this.contactFields[ci] || {}; 296 var fName = (f.name || "").toString(); 297 298 if (fName) { 299 var id = "fa_cf_" + fName.toLowerCase(); 300 var label = f.label || fName; 301 302 html += ` 303 <div class="col-12 col-md-6"> 304 <label class="form-label" for="${id}"> 305 ${label}${f.required ? ' <span class="text-danger">*</span>' : ""} 306 </label> 307 <input 308 type="${f.type || "text"}" 309 class="form-control" 310 id="${id}" 311 name="${fName}" 312 aria-required="${f.required ? "true" : "false"}" 313 ${f.placeholder ? `placeholder="${f.placeholder}"` : ""} 314 ${f.required ? "required" : ""} 315 /> 316 </div>`; 317 } 318 } 319 320 html += `</div>`; 321 322 var pollExt = 323 (this.model.poll && 324 (this.model.poll.extendedOptions || 325 this.model.poll.ExtendedOptions)) || 326 {}; 327 if (pollExt.showContactFormOptIn) { 328 var optLabel = 329 pollExt.contactFormOptInOverride || "Sign up to receive updates"; 330 html += ` 331 <div class="form-check mt-3"> 332 <input class="form-check-input" type="checkbox" id="fa_opt_in" name="OptIn" value="true" checked> 333 <label class="form-check-label" for="fa_opt_in">${optLabel}</label> 334 </div>`; 335 } 336 337 if (pollExt.enablePrivacyInfo) { 338 var privPrompt = pollExt.privacyPrompt || "How we respect your privacy"; 339 var privInfo = pollExt.customPrivacyInfo || ""; 340 html += ` 341 <div class="mt-2"> 342 <a href="javascript:void(0)" class="fa-privacy-info text-muted" data-bs-toggle="tooltip" title="${ 343 privInfo || privPrompt 344 }">${privPrompt}</a> 345 </div>`; 346 } 347 348 html += `</form>`; 349 } 350 351 html += ` 352 <div class="d-flex gap-3 justify-content-end mt-4"> 353 <button type="button" class="fa-reset btn btn-outline-secondary pi-btn-outline-secondary pi-btn-md" aria-label="Reset allocations">Reset Allocations</button> 354 <button type="button" class="fa-submit btn btn-primary pi-btn-primary pi-btn-md" disabled aria-disabled="true" aria-label="Submit allocations">Submit</button> 355 </div> 356 </div>`; 357 358 this.$el.html(html); 359 360 this.initSliders(); 361 this.initSlidersKeyboardNavigation(); 362 363 if (this.contactFields && this.contactFields.length) { 364 this.initContactForm(); 365 } 366 }; 367 368 // keyboard navigation for sliders: 369 FundingAllocator.prototype.initSlidersKeyboardNavigation = function () { 370 this.$el.find(".fa-slider").on("keydown", function (e) { 371 const slider = $(this); 372 const step = parseInt(slider.data("step")) || 1000; 373 const max = parseInt(slider.data("base-max")) || 0; 374 const min = 0; 375 let val = parseInt(slider.attr("aria-valuenow")) || 0; 376 377 switch (e.key) { 378 case "ArrowLeft": 379 case "ArrowDown": 380 val = Math.max(val - step, min); 381 break; 382 case "ArrowRight": 383 case "ArrowUp": 384 val = Math.min(val + step, max); 385 break; 386 case "Home": 387 val = min; 388 break; 389 case "End": 390 val = max; 391 break; 392 default: 393 return; 394 } 395 396 e.preventDefault(); 397 398 slider.attr("aria-valuenow", val); 399 slider.attr( 400 "aria-valuetext", 401 "$" +
402 val.toLocaleString("en-US") + 403 " of $" + 404 max.toLocaleString("en-US") 405 ); 406 slider.data("value", val); 407 408 slider 409 .siblings(".fa-slider-values") 410 .find(".fa-current") 411 .text("$" + val.toLocaleString("en-US")); 412 413 slider.closest(".fa-card").find(".fa-input").val(val); 414 }); 415 }; 416 417 FundingAllocator.prototype.initSliders = function () { 418 var self = this; 419 420 this.$el.find(".fa-card").each(function () { 421 var $card = $(this); 422 var sliderEl = $card.find(".fa-slider")[0]; 423 var start = parseFloat($card.find(".fa-slider").data("value")) || 0; 424 var max = 425 parseFloat($card.find(".fa-slider").data("base-max")) || self.total; 426 var rawStep = parseFloat($card.find(".fa-slider").data("step")) || 1000; 427 var step; 428 429 if (max <= 100) { 430 step = 10; 431 } else if (max <= 500) { 432 step = 25; 433 } else if (max <= 1000) { 434 step = 50; 435 } else if (max <= 5000) { 436 step = 100; 437 } else if (max <= 20000) { 438 step = 500; 439 } else { 440 step = rawStep; 441 } 442 443 if (step > max) { 444 step = Math.max(1, max / 10); 445 } 446 447 noUiSlider.create(sliderEl, { 448 start: [start], 449 connect: [true, false], 450 step: step, 451 range: { min: 0, max: max } 452 }); 453 454 var $input = $card.find(".fa-input"); 455 $input.data("slider", sliderEl); 456 457 sliderEl.noUiSlider.on("update", function (values) { 458 var v = Math.round(values[0]); 459 $input.val(v); 460 $card.find(".fa-current").text("$" + v.toLocaleString("en-US")); 461 var $slider = $card.find(".fa-slider"); 462 $slider.attr("aria-valuenow", v); 463 $slider.attr( 464 "aria-valuetext", 465 "$" + 466 v.toLocaleString("en-US") + 467 " of $" + 468 max.toLocaleString("en-US") 469 ); 470 self._toggleFundedState($card); 471 self.updateRemaining(); 472 }); 473 474 sliderEl.noUiSlider.on("slide", function (values, handle) { 475 var proposed = Math.round(values[handle]); 476 var spentOthers = self._spentExcludingCard($card); 477 var allowed = 478 self.total > 0 ? Math.min(max, self.total - spentOthers) : max; 479 480 if (proposed > allowed) { 481 proposed = allowed; 482 sliderEl.noUiSlider.set(proposed); 483 } 484 }); 485 486 sliderEl.noUiSlider.on("set", function () { 487 self.updateRemaining(); 488 }); 489 490 $input.on("input change", function () { 491 var v = parseFloat($(this).val()) || 0; 492 if (v < 0) { 493 v = 0; 494 } 495 if (v > max) { 496 v = max; 497 } 498 sliderEl.noUiSlider.set(v); 499 self.updateRemaining(); 500 }); 501 502 // Keyboard support for Left/Right arrows slide by 10% of max 503 var stepPercent = max * 0.1; 504 505 $card 506 .find(".fa-slider") 507 .attr("tabindex", 0) 508 .on("keydown", function (e) { 509 var currentVal = parseFloat(sliderEl.noUiSlider.get()) || 0; 510 var newVal = currentVal; 511 512 if (e.key === "ArrowLeft") { 513 newVal = Math.max(0, currentVal - stepPercent); 514 } else if (e.key === "ArrowRight") { 515 newVal = Math.min(max, currentVal + stepPercent); 516 } else { 517 return; // Ignore other keys 518 } 519 520 e.preventDefault(); 521 sliderEl.noUiSlider.set(newVal); 522 }); 523 }); 524 525 this.updateRemaining(); 526 }; 527 528 FundingAllocator.prototype.initContactForm = function () { 529 var self = this; 530 // Load parsley if not loaded 531 function loadParsley(callback) { 532 if (window.Parsley) { 533 callback(); 534 return; 535 } 536 if ( 537 window.ScriptLoader && 538 ScriptLoader.scriptArrays && 539 ScriptLoader.scriptArrays.parsley 540 ) { 541 ScriptLoader.loadScriptArray( 542 ScriptLoader.scriptArrays.parsley, 543 callback 544 ); 545 } else { 546 $.getScript( 547 "https://cdnjs.cloudflare.com/ajax/libs/parsley.js/2.9.2/parsley.min.js", 548 callback 549 ); 550 } 551 } 552 553 loadParsley(function () { 554 var $form = self.$el.find(".fa-contact-form"); 555 if ($form.length) { 556 $form.parsley(); 557 $form.on("input change", "input,select,textarea", function () { 558 self.updateRemaining(); 559 }); 560 self.updateRemaining(); 561 // Initialize bootstrap tooltips for privacy info links 562 if (window.bootstrap && typeof bootstrap.Tooltip === "function") { 563 self.$el.find(".fa-privacy-info").each(function () { 564 new bootstrap.Tooltip(this); 565 }); 566 } else if ($.fn.tooltip) { 567 self.$el.find(".fa-privacy-info").tooltip(); 568 } 569 } 570 });
571 }; 572 573 FundingAllocator.prototype.submit = function () { 574 var self = this; 575 var payload = { pollId: this.pollId, allocations: [] }; 576 this.$el.find(".fa-card").each(function () { 577 var $card = $(this); 578 payload.allocations.push({ 579 optionId: $card.data("option-id"), 580 amount: parseFloat($card.find(".fa-input").val()) || 0 581 }); 582 }); 583 584 // contact data 585 var $contactForm = this.$el.find(".fa-contact-form"); 586 if ($contactForm.length) { 587 if (window.Parsley) { 588 $contactForm.parsley().validate(); 589 if (!$contactForm.parsley().isValid()) { 590 return; // invalid form 591 } 592 } 593 var contactData = {}; 594 $contactForm.serializeArray().forEach(function (kv) { 595 contactData[kv.name] = kv.value; 596 }); 597 payload.contactData = contactData; 598 } 599 600 // Immediately disable inputs and show pending spinner banner 601 this.disableInputs(); 602 this.showPendingSpinner(); 603 604 // Get anti-forgery token with fallback 605 var token = window.ajaxAntiForgeryToken; 606 if (!token) { 607 var $tokenInput = $('input[name="__RequestVerificationToken"]').first(); 608 if ($tokenInput.length) { 609 token = $tokenInput.val(); 610 } 611 } 612 613 // Flatten payload into MVC model-binderâfriendly keys (e.g., allocations[0].OptionId) 614 var postData = { 615 pollId: this.pollId 616 }; 617 618 payload.allocations.forEach(function (alloc, idx) { 619 postData["allocations[" + idx + "].OptionId"] = alloc.optionId; 620 postData["allocations[" + idx + "].Amount"] = alloc.amount; 621 }); 622 623 // Contact data keys -> ContactData[Key] 624 if (payload.contactData) { 625 Object.keys(payload.contactData).forEach(function (k) { 626 postData["contactData[" + k + "]"] = payload.contactData[k]; 627 }); 628 } 629 630 // Anti-forgery token 631 if (token) { 632 postData.__RequestVerificationToken = token; 633 } 634 635 var ajaxOptions = { 636 url: "/FundingAllocator/Submit", 637 type: "POST", 638 data: postData, 639 traditional: true // ensure arrays stay indexed correctly 640 }; 641 642 $.ajax(ajaxOptions) 643 .done(function () { 644 self.isSubmitted = true; 645 // Replace spinner with success content 646 self.showSuccessMessage(); 647 if (self.redirectUrl) { 648 window.open(self.redirectUrl, "_blank"); 649 } 650 self._markPollAsVoted(); // Mark poll as voted after successful submission 651 }) 652 .fail(function () { 653 // Remove pending message and re-enable inputs on error 654 self.$el.find(".fa-success-message").remove(); 655 self.enableInputs(); 656 self.$el.find(".fa-submit").removeClass("d-none"); 657 alert( 658 "An error occurred while submitting your preferences. Please try again." 659 ); 660 }); 661 }; 662 663 FundingAllocator.prototype.resetAllocations = function () { 664 var self = this; 665 this.$el.find(".fa-card").each(function () { 666 var $card = $(this); 667 $card.find(".fa-input").val(0); 668 var slider = $card.find(".fa-slider")[0]; 669 if (slider && slider.noUiSlider) { 670 slider.noUiSlider.set(0); 671 } 672 self._toggleFundedState($card); 673 }); 674 this.budgetLimitNotified = false; 675 this.updateRemaining(); 676 }; 677 678 // Helper: hide contact form 679 FundingAllocator.prototype._toggleContactForm = function (show) { 680 var $f = this.$el.find(".fa-contact-form"); 681 if (!$f.length) { 682 return; 683 } 684 if (show) { 685 $f.removeClass("d-none"); 686 } else { 687 $f.addClass("d-none"); 688 } 689 }; 690 691 // Show green banner with spinner while waiting for server response 692 FundingAllocator.prototype.showPendingSpinner = function () { 693 // Remove existing success banners 694 this.$el.find(".fa-success-message").remove(); 695 var spinnerHtml = 696 window.TOOLS && TOOLS.showLoadingSpinner 697 ? TOOLS.showLoadingSpinner() 698 : '<div class="text-center p-3"><div class="spinner-border" role="status"></div></div>'; 699 var html = 700 '<div class="fa-success-message text-center">' + spinnerHtml + "</div>"; 701 var $footer = this.$el.find(".fa-footer-outline"); 702 if ($footer.length) { 703 var $form = $footer.find(".fa-contact-form"); 704 if ($form.length) { 705 $form.after(html); 706 this._toggleContactForm(false); 707 } else { 708 $footer.prepend(html); 709 } 710 $footer.find(".fa-submit").addClass("d-none"); 711 $footer.find(".fa-reset").addClass("d-none"); 712 } else { 713 this.$el.prepend(html); 714 } 715 }; 716 717 // Disable sliders/inputs after submit 718 FundingAllocator.prototype.disableInputs = function () { 719 this.$el.find(".fa-input").prop("disabled", true); 720 this.$el 721 .find(".fa-slider") 722 .each(function () { 723 this.setAttribute("disabled", true); 724 }) 725 .addClass("disabled"); 726 this.$el.find(".fa-submit").prop("disabled", true); 727 }; 728 729 // Enable inputs for editing 730 FundingAllocator.prototype.enableInputs = function () { 731 this.$el.find(".fa-input").prop("disabled", false); 732 this.$el 733 .find(".fa-slider") 734 .each(function () { 735 this.removeAttribute("disabled"); 736 }) 737 .removeClass("disabled"); 738 this.$el.find(".fa-submit").prop("disabled", false); 739 }; 740 741 FundingAllocator.prototype.enableEditing = function () { 742 this.isSubmitted = false; 743 this.budgetLimitNotified = false; 744 this.$el.find(".fa-footer-outline").removeClass("success-state"); 745 this.$el.find(".fa-success-message").remove(); 746 this._toggleContactForm(true); 747 this.enableInputs(); 748 this.$el.find(".fa-submit").removeClass("d-none"); 749 this.updateRemaining(); 750 }; 751 752 FundingAllocator.prototype.showSuccessMessage = function () { 753 // Remove if exists 754 this.$el.find(".fa-success-message").remove(); 755 var html = 756 '<div class="fa-success-message text-center">' + 757 '<h2 class="text-dark">Thank You!</h2>' + 758 '<p class="text-dark">Your response has been submitted.</p>'; 759 if (!this.pollClosed) { 760 html += 761 ' <button type="button" class="btn btn-outline-primary pi-btn-outline-primary mt-3 fa-edit-responses">Edit Responses</button>'; 762 } 763 html += "</div>"; 764 765 var $footer = this.$el.find(".fa-footer-outline"); 766 if ($footer.length) { 767 $footer.addClass("success-state"); 768 // Hide submit/reset buttons when success shown 769 $footer.find(".fa-submit").addClass("d-none"); 770 $footer.find(".fa-reset").addClass("d-none"); 771 // If contact form exists, append after it; else just prepend 772 var $form = $footer.find(".fa-contact-form"); 773 if ($form.length) { 774 $form.after(html); 775 this._toggleContactForm(false); 776 } else { 777 $footer.prepend(html); 778 } 779 } else { 780 // fallback 781 this.$el.prepend(html); 782 } 783 }; 784 785 FundingAllocator.prototype.updateRemaining = function () { 786 var spent = 0; 787 this.$el.find(".fa-input").each(function () { 788 spent += parseFloat($(this).val()) || 0; 789 });
790 791 // Only update budget display if total budget is set 792 if (this.total > 0) { 793 var remaining = this.total - spent; 794 if (remaining < 0) { 795 remaining = 0; 796 } 797 798 this.$el 799 .find(".fa-remaining-value") 800 .text("$" + remaining.toLocaleString("en-US")); 801 802 var percSpent = (spent / this.total) * 100; 803 if (percSpent > 100) { 804 percSpent = 100; 805 } 806 this.$el.find(".fa-progress-bar").css("width", percSpent + "%"); 807 } 808 809 var $btn = this.$el.find(".fa-submit"); 810 811 var budgetLimitReached = 812 this.total > 0 && Math.abs(this.total - spent) < 0.01; 813 814 if (budgetLimitReached && !this.budgetLimitNotified) { 815 // Show notification when budget limit is reached 816 this._showNotification("success", "You've allocated your entire budget!"); 817 this.budgetLimitNotified = true; 818 } else if (remaining > 0.01) { 819 this.budgetLimitNotified = false; 820 } 821 822 // If poll closed, never allow submit 823 if (this.pollClosed) { 824 $btn.prop("disabled", true); 825 this._showNotification("danger", "This poll has closed"); 826 return; 827 } 828 829 if (this.isSubmitted) { 830 $btn.prop("disabled", true); 831 return; 832 } 833 834 var meetsMinimum = this.minimumSpend === 0 || spent >= this.minimumSpend; 835 var contactValid = true; 836 var $contactForm = this.$el.find(".fa-contact-form"); 837 if ($contactForm.length && window.Parsley) { 838 contactValid = $contactForm.parsley().isValid({ force: true }); 839 } 840 841 $btn.prop("disabled", !(meetsMinimum && contactValid)); 842 $btn.text(!meetsMinimum ? "Submit (minimum not met)" : "Submit"); 843 }; 844 845 FundingAllocator.prototype._toggleFundedState = function ($card) { 846 var val = parseFloat($card.find(".fa-input").val() || 0); 847 var funded = val > 0; 848 $card.toggleClass("border-success border-2", funded); 849 $card.find(".fa-funded-header").toggleClass("d-none", !funded); 850 }; 851 852 FundingAllocator.prototype._spentExcludingCard = function ($exclude) { 853 var spent = 0; 854 this.$el.find(".fa-card").each(function () { 855 var $c = $(this); 856 if ($c.is($exclude)) { 857 return; 858 } // skip 859 spent += parseFloat($c.find(".fa-input").val()) || 0; 860 }); 861 return spent; 862 }; 863 864 // Helper method to show notifications with fallback 865 FundingAllocator.prototype._showNotification = function (type, message) { 866 if (window.BootstrapToast && window.BootstrapToast.showToast) { 867 window.BootstrapToast.showToast(type, message); 868 } 869 }; 870 871 win.FundingAllocator = FundingAllocator; 872})(window);
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.