PageSourceSearch

https://nystransportationmasterplan.com/Scripts/FundingAllocator/FundingAllocator.js

js nystransportationmasterplan.com collected 2026-09-29 15:07:10 UTC 28,657 bytes, 872 lines download raw bytes

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.