Commit e8f1d387 authored by stephen's avatar stephen
Browse files

feat: Bring CAPTCHA validation feedback in line with main donate form

The standalone newsletter signup form is simple by design; however, the experience of bumping into the backend form validation is perhaps a little too simple, especially when compared with the Bootstrap-style validation provided by the larger and more complex donation form. This MR seeks to bring that experience in line with the mainstay form by implementing the AJAX CAPTCHA pre-submission validation layer utilized by the main donation form.

- `tordonate.civicrm.views` sees SubscriptionForm expanded with a new internal method, `validate_captcha()`, which accepts an `HTTPRequest` containing the form data and validates it on the fly, returning a JSON string of errors if any are found, and processing the subscription request if none are.
- `tordonate.civicrm.urls` adds a route for this new method for the front end to leverage.
- `script.js` adds a handler for the subscription form's `submit` action, circumventing the native form behavior (to prevent a race condition between the near-instantaneous processing native HTML will perform and the slightly-slower Fetch API call and response which would validate the CAPTCHA). Errors with the form are painted onto the UI; if no errors were present, the user is forwarderd to the `/subscribed/` page.
- Users with Javascript disabled will not experience any changes, and can continue using the form as normal.
parent 43130161
Loading
Loading
Loading
Loading
+40 −0
Original line number Diff line number Diff line
@@ -402,6 +402,46 @@ dTor.init = function () {
    dTor.validateElement(this);
  });

  /* bootstrap5 validation for subscription form */
  $("form#subscriptionForm").on("submit", async function (e) {
    e.preventDefault();
    e.stopPropagation();
    $(this).addClass("was-validated");

    var subFormData = $("#subscriptionForm").serializeArray();
    var subFormParam = $.param(subFormData);
    var subFormValidate = await fetch("/validate-subscription/", {
      method: "POST",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        "X-CSRFToken": dTor.csrfToken,
      },
      body: JSON.stringify({
        form: subFormParam, 
      }),
    });

    const validateData = await subFormValidate.json();

    if(validateData.errors) {
      if(typeof validateData.errors === 'string' || validateData.errors instanceof String) {
        validateData.errors = JSON.parse(validateData.errors)
      }
      
      dTor.handleFormErrors(validateData.errors);
      e.preventDefault();
      e.stopPropagation();
    } else {
      window.location = "/subscribed/"
    }
  })
  .on("change keyup", "input", function(e) {
    // keep hidden field used by BTCPay synced with user-accessible field
    $("#price").val($("#customDonation").val());
    dTor.validateElement(this);
  });

  /* click-to-copy button on crypto fields */
  $("#secCryptoAddresses")
    .on("click", ".currencyAddress--copy", function (e) {
+3 −0
Original line number Diff line number Diff line
@@ -7,6 +7,9 @@ from .views import (OptoutView, ResubscriptionView, SubscribedView, Subscription
urlpatterns = [
    path("civicrm/mailing/<str:action>", views.mailing_action, name="mailing_action"),
    path("confirm-subscription", views.confirm_subscription, name="confirm_subscription"),
    path(
        "validate-subscription/", SubscriptionView.validate_captcha, name="validate_subscription"
    ),
    path("subscribe/", SubscriptionView.as_view(), name="subscribe"),
    path("subscribed/", SubscribedView.as_view(), name="subscribed"),
    path(
+17 −1
Original line number Diff line number Diff line
import json
import logging
import typing as t

from dependency_injector.wiring import Provide, inject
from django.http import HttpRequest, HttpResponse
from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed, JsonResponse, QueryDict
from django.views.generic import FormView, TemplateView

from ..containers import Container
@@ -44,6 +45,21 @@ class SubscriptionView(FormView):
        context.update({})
        return context  # type: ignore[no-any-return]

    def validate_captcha(
        request: HttpRequest
    ) -> JsonResponse:
        if request.method != "POST":
            return HttpResponseNotAllowed(["POST"])

        jsonData = json.loads(request.body)
        data = QueryDict(jsonData["form"].encode("ASCII"))
        form = SubscriptionForm(data)

        if form.is_valid():
            return JsonResponse({})
        else:
            return JsonResponse({"errors": form.errors.as_json()})

    @inject
    def form_valid(  # type: ignore
        self,
+1 −1
Original line number Diff line number Diff line
import{initLocations,filterStates}from"./modules/locations.min.js";import{stripeInit,stripeUpdate,stripeSubmit,stripeResults,haveStripeClientSecret}from"./modules/stripe.min.js";import{paypalInit,paypalDonationSet,setupPaypalOneTimeButtons,setupPaypalSubscriptionButtons,paypalResults}from"./modules/paypal.min.js";var dTor=dTor||{};dTor.baseUrl=window.location.origin,dTor.page=$("body").attr("id")||"",dTor.csrfToken=$("[name=csrfmiddlewaretoken]").val(),dTor.minimumDonation=document.getElementById("minimum-donation-data")?JSON.parse(document.getElementById("minimum-donation-data").textContent):{},dTor.init=function(){switch(initLocations(),this.refreshCaptcha=function(){$.getJSON("/captcha/refresh/",(function(result){let captcha_audio=document.getElementById("captcha-audio");$("img.captcha").attr("src",result.image_url),$("#id_captcha_0").attr("value",result.key),$("#id_captcha_1").val(""),$(captcha_audio).find("source").attr("src",result.audio_url),captcha_audio.pause(),captcha_audio.load()}))},this.displayPaymentError=function(message){$("form.was-validated").removeClass("was-validated"),dTor.refreshCaptcha();$("#result-message").html(message).removeClass("hidden")},this.hidePaymentError=function(){$("#result-message").html("").addClass("hidden")},this.handleFormErrors=function(errors){$("form.was-validated").removeClass("was-validated"),dTor.refreshCaptcha();for(const[key,value]of Object.entries(errors))if("captcha"===key)$("#id_captcha_1").addClass("is-invalid");else $(`[name='${key}']`).addClass("is-invalid")},this.setupFormValidation=function(){dTor.hidePaymentError(),$("form").on("submit",(function(e){!this.checkValidity()||$(e.currentTarget).data("shippingRestricted")?(e.preventDefault(),e.stopPropagation(),$.fn.matchHeight._update(),$(e.currentTarget).data("shippingRestricted")&&dTor.displayPaymentError("Due to shipping restrictions, we currently can not ship to Ukraine, or Russia. We apologize for the inconvenience.")):dTor.beginPaymentProcess(e),$(this).addClass("was-validated")}))},this.validateElement=function(el){el.checkValidity()&&null!=$(el).val()?$(el).removeClass("is-invalid"):($(el).siblings(".invalid-feedback").html(el.validationMessage),$(el).addClass("is-invalid"))},this.formSyncInput=function(e){let _this=$(this);"button"!=_this.attr("type")&&_this.attr("required")&&dTor.validateElement(this)},this.formSyncSelect=function(e){let _this=$(this),_thisLabel=$(this).siblings("label");(_this.attr("required")||_thisLabel.hasClass("required"))&&dTor.validateElement(this)},this.propagateDonation=function(d){var dShow="$"+d/100,frequency=$("#pricegrid").data("frequency")||"single";(parseInt(d)>=dTor.minimumDonation.cents&&stripeUpdate(d,frequency),parseInt(d)>=dTor.minimumDonation.cents&&paypalDonationSet(d),$("#noPerkCheckbox")&&$(".Perk-selection").each((function(i,el){var _el=$(el);_el.data("price-tier-"+frequency)<=d?(_el.find("input[type='radio']").removeAttr("disabled"),_el.removeClass("Perk-inactive").addClass("Perk-active")):(_el.find("[id^='id_perk']").prop("checked",!1).attr("disabled","disabled"),_el.removeClass("Perk-active").addClass("Perk-inactive"))})),$(".Perk-selection.Perk-active").length||$("#noPerkCheckbox").is(":checked")||$("#noPerkCheckbox").prop("checked",!0),$("#cryptoContainer").length)&&($("#secBTCPay").hasClass("hidden")||($("#btn-donateBTCPay")[0].innerHTML="Donate <span>"+dShow+"</span> by BTCPay"))},this.togglePerks=function(perkId){$(".perk_option").each((function(i,el){let _el=$(el);perkId==_el.data("perk")?(_el.removeClass("hidden"),_el.find("select").attr("required","required"),_el.find(".hiddenPerkOption").removeAttr("disabled")):(_el.hasClass("hidden")||_el.addClass("hidden"),_el.find("select").removeAttr("required"),_el.find(".hiddenPerkOption").attr("disabled","disabled"))}))},this.beginPaymentProcess=function(e){e.preventDefault(),$("#checkout_card").hasClass("hidden")||stripeSubmit().then((function(errors){errors&&dTor.handleFormErrors(errors)}))},$(".donate-form, #subscriptionForm").on("change keyup","input",this.formSyncInput).on("change keyup blur","select",this.formSyncSelect),$(".sectionToggle").on("click","[data-toggles]",(function(e){e.preventDefault();var _this=$(this);_this.addClass("active").siblings("[data-toggles]").removeClass("active");var targetID=_this.data("toggles");$(".toggleable").each((function(){$(this).is(targetID)?$(this).removeClass("hidden"):$(this).addClass("hidden")})),$.fn.matchHeight._update()})),$(".frequencyToggle").on("click","[data-toggles_price]",(function(e){var _this=$(this),targetID=_this.data("toggles_price"),frequency=_this.data("toggles_to"),currentVal=100*$("#customDonation").val();$(".Perk").removeClass("single monthly").addClass(frequency),$("#pricegrid").data("frequency",frequency).find(":checked").removeAttr("checked"),$(".toggleablePrice").each((function(){if($(this).is(targetID)){let checkableChildren=$(this).find("input[value='"+currentVal+"']");checkableChildren&&checkableChildren.prop("checked",!0),$(this).removeClass("hidden")}else $(this).addClass("hidden")})),$("#checkout_paypal").removeClass("single monthly").addClass(frequency),dTor.propagateDonation(currentVal),$.fn.matchHeight._update()})).on("blur","input[id^='customDonation']",(function(e){var _this=$(this);dTor.propagateDonation(100*_this[0].value)})),$("[id^='pricegrid_']").on("click","input[type='radio']",(function(e){var _this=$(this),_customInput=$("#pricegrid").find("input[id^='customDonation']"),donationAmount=_this[0].value,donationDisplay=donationAmount/100;_customInput[0].value=donationDisplay,dTor.propagateDonation(donationAmount)})),$("#pricegrid").on("change, keyup","#customDonation",(function(e){var _this=$(this);$("#pricegrid").find(".toggleablePrice").not(".hidden").find("input[type='radio']").each((function(i,e){var _e=$(e);100*_this[0].value==_e.val()?(_e.prop("checked",!0),_e.attr("checked","checked")):_e.prop("checked",!1)})),$.fn.matchHeight._update(),dTor.propagateDonation(100*_this[0].value)})),$(".Card-gift").on("click","#noPerkCheckbox",(function(e){$(this).prop("checked")&&($(".Card-gift").find("input[type='radio']").prop("checked",!1),dTor.togglePerks(0))})).on("click","input[type='radio']",(function(e){var _this=$(this);_this.prop("checked")&&($("#noPerkCheckbox").prop("checked",!1),dTor.togglePerks(_this.val()))})),$("form").on("change keyup blur","#id_country",(function(e){var cName=$(e.currentTarget).val(),cOption=$(e.currentTarget).find('option[value="'+cName+'"]'),cId=cOption.data("countryid"),parentForm=$(e.currentTarget).closest("form");filterStates(cOption.data("countryid")),182==cId||230==cId?(parentForm.find(".shippingRestricted").removeClass("hidden"),parentForm.data("shippingRestricted",!0)):(parentForm.find(".shippingRestricted").hasClass("hidden")||parentForm.find(".shippingRestricted").addClass("hidden"),parentForm.data("shippingRestricted",!1))})),$(".perk_option").on("change keyup blur","select.optionGroup",(function(e){$(this).closest(".perk_option").find(".optionList").removeAttr("required").filter(":not(.hidden)").addClass("hidden").val(""),$("#perk_option--"+$(e.currentTarget).val()).removeClass("hidden").attr("required","required"),$("label[data-revealed-by='optionGroup-"+$(e.currentTarget).data("selectid")+"']").removeClass("hidden")})),$("form#cryptoBTCPay").on("submit",(function(e){this.checkValidity()||(e.preventDefault(),e.stopPropagation()),$(this).addClass("was-validated")})).on("change keyup","input",(function(e){$("#price").val($("#customDonation").val()),dTor.validateElement(this)})),$("#secCryptoAddresses").on("click",".currencyAddress--copy",(function(e){e.preventDefault();var address=$(this).siblings(".currencyAddress").attr("value");navigator.clipboard.writeText(address)})).on("click",".currencyAddress--openModal",(function(e){e.preventDefault();$(this)})),$(".Faq--Nav").on("click","button",(function(e){$(".Faq--Content").find(".collapse").collapse("hide")})),dTor.page){case"donate":setupPaypalOneTimeButtons(),setupPaypalSubscriptionButtons(),stripeInit(12500,dTor.csrfToken,dTor.baseUrl,dTor.displayPaymentError,dTor.handleFormErrors),paypalInit(12500,dTor.csrfToken,dTor.baseUrl,dTor.displayPaymentError,dTor.handleFormErrors),dTor.setupFormValidation(),dTor.propagateDonation(12500);break;case"thankyou":stripeResults(),paypalResults()}},window.onload=dTor.init();
 No newline at end of file
import{initLocations,filterStates}from"./modules/locations.min.js";import{stripeInit,stripeUpdate,stripeSubmit,stripeResults,haveStripeClientSecret}from"./modules/stripe.min.js";import{paypalInit,paypalDonationSet,setupPaypalOneTimeButtons,setupPaypalSubscriptionButtons,paypalResults}from"./modules/paypal.min.js";var dTor=dTor||{};dTor.baseUrl=window.location.origin,dTor.page=$("body").attr("id")||"",dTor.csrfToken=$("[name=csrfmiddlewaretoken]").val(),dTor.minimumDonation=document.getElementById("minimum-donation-data")?JSON.parse(document.getElementById("minimum-donation-data").textContent):{},dTor.init=function(){switch(initLocations(),this.refreshCaptcha=function(){$.getJSON("/captcha/refresh/",(function(result){let captcha_audio=document.getElementById("captcha-audio");$("img.captcha").attr("src",result.image_url),$("#id_captcha_0").attr("value",result.key),$("#id_captcha_1").val(""),$(captcha_audio).find("source").attr("src",result.audio_url),captcha_audio.pause(),captcha_audio.load()}))},this.displayPaymentError=function(message){$("form.was-validated").removeClass("was-validated"),dTor.refreshCaptcha();$("#result-message").html(message).removeClass("hidden")},this.hidePaymentError=function(){$("#result-message").html("").addClass("hidden")},this.handleFormErrors=function(errors){$("form.was-validated").removeClass("was-validated"),dTor.refreshCaptcha();for(const[key,value]of Object.entries(errors))if("captcha"===key)$("#id_captcha_1").addClass("is-invalid");else $(`[name='${key}']`).addClass("is-invalid")},this.setupFormValidation=function(){dTor.hidePaymentError(),$("form").on("submit",(function(e){!this.checkValidity()||$(e.currentTarget).data("shippingRestricted")?(e.preventDefault(),e.stopPropagation(),$.fn.matchHeight._update(),$(e.currentTarget).data("shippingRestricted")&&dTor.displayPaymentError("Due to shipping restrictions, we currently can not ship to Ukraine, or Russia. We apologize for the inconvenience.")):dTor.beginPaymentProcess(e),$(this).addClass("was-validated")}))},this.validateElement=function(el){el.checkValidity()&&null!=$(el).val()?$(el).removeClass("is-invalid"):($(el).siblings(".invalid-feedback").html(el.validationMessage),$(el).addClass("is-invalid"))},this.formSyncInput=function(e){let _this=$(this);"button"!=_this.attr("type")&&_this.attr("required")&&dTor.validateElement(this)},this.formSyncSelect=function(e){let _this=$(this),_thisLabel=$(this).siblings("label");(_this.attr("required")||_thisLabel.hasClass("required"))&&dTor.validateElement(this)},this.propagateDonation=function(d){var dShow="$"+d/100,frequency=$("#pricegrid").data("frequency")||"single";(parseInt(d)>=dTor.minimumDonation.cents&&stripeUpdate(d,frequency),parseInt(d)>=dTor.minimumDonation.cents&&paypalDonationSet(d),$("#noPerkCheckbox")&&$(".Perk-selection").each((function(i,el){var _el=$(el);_el.data("price-tier-"+frequency)<=d?(_el.find("input[type='radio']").removeAttr("disabled"),_el.removeClass("Perk-inactive").addClass("Perk-active")):(_el.find("[id^='id_perk']").prop("checked",!1).attr("disabled","disabled"),_el.removeClass("Perk-active").addClass("Perk-inactive"))})),$(".Perk-selection.Perk-active").length||$("#noPerkCheckbox").is(":checked")||$("#noPerkCheckbox").prop("checked",!0),$("#cryptoContainer").length)&&($("#secBTCPay").hasClass("hidden")||($("#btn-donateBTCPay")[0].innerHTML="Donate <span>"+dShow+"</span> by BTCPay"))},this.togglePerks=function(perkId){$(".perk_option").each((function(i,el){let _el=$(el);perkId==_el.data("perk")?(_el.removeClass("hidden"),_el.find("select").attr("required","required"),_el.find(".hiddenPerkOption").removeAttr("disabled")):(_el.hasClass("hidden")||_el.addClass("hidden"),_el.find("select").removeAttr("required"),_el.find(".hiddenPerkOption").attr("disabled","disabled"))}))},this.beginPaymentProcess=function(e){e.preventDefault(),$("#checkout_card").hasClass("hidden")||stripeSubmit().then((function(errors){errors&&dTor.handleFormErrors(errors)}))},$(".donate-form, #subscriptionForm").on("change keyup","input",this.formSyncInput).on("change keyup blur","select",this.formSyncSelect),$(".sectionToggle").on("click","[data-toggles]",(function(e){e.preventDefault();var _this=$(this);_this.addClass("active").siblings("[data-toggles]").removeClass("active");var targetID=_this.data("toggles");$(".toggleable").each((function(){$(this).is(targetID)?$(this).removeClass("hidden"):$(this).addClass("hidden")})),$.fn.matchHeight._update()})),$(".frequencyToggle").on("click","[data-toggles_price]",(function(e){var _this=$(this),targetID=_this.data("toggles_price"),frequency=_this.data("toggles_to"),currentVal=100*$("#customDonation").val();$(".Perk").removeClass("single monthly").addClass(frequency),$("#pricegrid").data("frequency",frequency).find(":checked").removeAttr("checked"),$(".toggleablePrice").each((function(){if($(this).is(targetID)){let checkableChildren=$(this).find("input[value='"+currentVal+"']");checkableChildren&&checkableChildren.prop("checked",!0),$(this).removeClass("hidden")}else $(this).addClass("hidden")})),$("#checkout_paypal").removeClass("single monthly").addClass(frequency),dTor.propagateDonation(currentVal),$.fn.matchHeight._update()})).on("blur","input[id^='customDonation']",(function(e){var _this=$(this);dTor.propagateDonation(100*_this[0].value)})),$("[id^='pricegrid_']").on("click","input[type='radio']",(function(e){var _this=$(this),_customInput=$("#pricegrid").find("input[id^='customDonation']"),donationAmount=_this[0].value,donationDisplay=donationAmount/100;_customInput[0].value=donationDisplay,dTor.propagateDonation(donationAmount)})),$("#pricegrid").on("change, keyup","#customDonation",(function(e){var _this=$(this);$("#pricegrid").find(".toggleablePrice").not(".hidden").find("input[type='radio']").each((function(i,e){var _e=$(e);100*_this[0].value==_e.val()?(_e.prop("checked",!0),_e.attr("checked","checked")):_e.prop("checked",!1)})),$.fn.matchHeight._update(),dTor.propagateDonation(100*_this[0].value)})),$(".Card-gift").on("click","#noPerkCheckbox",(function(e){$(this).prop("checked")&&($(".Card-gift").find("input[type='radio']").prop("checked",!1),dTor.togglePerks(0))})).on("click","input[type='radio']",(function(e){var _this=$(this);_this.prop("checked")&&($("#noPerkCheckbox").prop("checked",!1),dTor.togglePerks(_this.val()))})),$("form").on("change keyup blur","#id_country",(function(e){var cName=$(e.currentTarget).val(),cOption=$(e.currentTarget).find('option[value="'+cName+'"]'),cId=cOption.data("countryid"),parentForm=$(e.currentTarget).closest("form");filterStates(cOption.data("countryid")),182==cId||230==cId?(parentForm.find(".shippingRestricted").removeClass("hidden"),parentForm.data("shippingRestricted",!0)):(parentForm.find(".shippingRestricted").hasClass("hidden")||parentForm.find(".shippingRestricted").addClass("hidden"),parentForm.data("shippingRestricted",!1))})),$(".perk_option").on("change keyup blur","select.optionGroup",(function(e){$(this).closest(".perk_option").find(".optionList").removeAttr("required").filter(":not(.hidden)").addClass("hidden").val(""),$("#perk_option--"+$(e.currentTarget).val()).removeClass("hidden").attr("required","required"),$("label[data-revealed-by='optionGroup-"+$(e.currentTarget).data("selectid")+"']").removeClass("hidden")})),$("form#cryptoBTCPay").on("submit",(function(e){this.checkValidity()||(e.preventDefault(),e.stopPropagation()),$(this).addClass("was-validated")})).on("change keyup","input",(function(e){$("#price").val($("#customDonation").val()),dTor.validateElement(this)})),$("form#subscriptionForm").on("submit",(async function(e){e.preventDefault(),e.stopPropagation(),$(this).addClass("was-validated");var subFormData=$("#subscriptionForm").serializeArray(),subFormParam=$.param(subFormData),subFormValidate=await fetch("/validate-subscription/",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json","X-CSRFToken":dTor.csrfToken},body:JSON.stringify({form:subFormParam})});const validateData=await subFormValidate.json();validateData.errors?(("string"==typeof validateData.errors||validateData.errors instanceof String)&&(validateData.errors=JSON.parse(validateData.errors)),dTor.handleFormErrors(validateData.errors),e.preventDefault(),e.stopPropagation()):window.location="/subscribed/"})).on("change keyup","input",(function(e){$("#price").val($("#customDonation").val()),dTor.validateElement(this)})),$("#secCryptoAddresses").on("click",".currencyAddress--copy",(function(e){e.preventDefault();var address=$(this).siblings(".currencyAddress").attr("value");navigator.clipboard.writeText(address)})).on("click",".currencyAddress--openModal",(function(e){e.preventDefault();$(this)})),$(".Faq--Nav").on("click","button",(function(e){$(".Faq--Content").find(".collapse").collapse("hide")})),dTor.page){case"donate":setupPaypalOneTimeButtons(),setupPaypalSubscriptionButtons(),stripeInit(12500,dTor.csrfToken,dTor.baseUrl,dTor.displayPaymentError,dTor.handleFormErrors),paypalInit(12500,dTor.csrfToken,dTor.baseUrl,dTor.displayPaymentError,dTor.handleFormErrors),dTor.setupFormValidation(),dTor.propagateDonation(12500);break;case"thankyou":stripeResults(),paypalResults()}},window.onload=dTor.init();
 No newline at end of file