Skip to content

Instantly share code, notes, and snippets.

@callaginn
Last active April 25, 2024 22:22
Show Gist options
  • Star 16 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save callaginn/7e64626dc6d648936ff0e4f3d83a5304 to your computer and use it in GitHub Desktop.
Save callaginn/7e64626dc6d648936ff0e4f3d83a5304 to your computer and use it in GitHub Desktop.
Shopify Ajax Contact Form
// Before implementing this, you'll need to contact Shopify support and ask them to turn off Google's ReCaptcha
// for your Shopify store's contact forms. Otherwise, it will redirect to the captcha's verification page.
// Retrieves input data from a form and returns it as a JSON object:
function formToJSON(elements) {
return [].reduce.call(elements, function (data, element) {
data[element.name] = element.value;
return data;
}, {});
}
// Get Shopify Friendly URL String
function getUrlString(data) {
var urlParameters = Object.entries(data).map(function (e) {
return e.join('=');
}).join('&');
return urlParameters;
}
function getUrlParameter(sParam) {
var sPageURL = decodeURIComponent(window.location.search.substring(1)),
sURLVariables = sPageURL.split('&'),
sParameterName,
i;
for (i = 0; i < sURLVariables.length; i++) {
sParameterName = sURLVariables[i].split('=');
if (sParameterName[0] === sParam) {
return sParameterName[1] === undefined ? true : sParameterName[1];
}
}
}
function ajaxFormInit(form) {
var form_type = form.querySelector("[name=form_type]").value,
inputs = form.querySelectorAll("[name]"),
alert = form.querySelector('[data-alert="status"]'),
alert_msgs = form.querySelector('.form-alerts');
form.addEventListener('submit', function(e){
e.preventDefault();
var action = form.getAttribute("action");
if (alert_msgs) {
var alert_msg = JSON.parse(alert_msgs.innerHTML)
}
console.log("Form Action: " + action);
console.log("Submitting " + form_type + " form...");
fetch(action, {
method: 'POST',
body: getUrlString(formToJSON(inputs)),
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'Accept': 'text/html, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function(response) {
console.log(response);
console.log(response.status);
if (alert) {
alert.className = "alert alert-success";
alert.innerHTML = alert_msg.success;
}
var checkoutUrl = getUrlParameter("checkout_url");
if (checkoutUrl) {
window.location = getUrlParameter("checkout_url");
} else if (response.status === 200 && form_type !== "contact") {
window.location.pathname = "/account"
}
}).catch(function(err) {
console.error(err);
if (alert) {
alert.className = "alert alert-error";
alert.innerHTML = alert_msg.error;
}
});
});
}
// Init Shopify Forms
document.querySelectorAll("[name=form_type]").forEach(function(el) {
ajaxFormInit(el.closest("form"));
});
{% comment %}
Contact Form Wide
{% endcomment %}
{%- form 'contact' -%}
<div class="row">
<div class="col-md-4 mb-3">
<label for="name">{{ 'contact.form.name' | t }} <i class="fa fa-asterisk"></i></label>
<input type="text" class="form-control" name="contact[name]" autocomplete="name" required>
</div>
<div class="col-md-4 mb-3">
<label for="email">{{ 'contact.form.email' | t }} <i class="fa fa-asterisk"></i></label>
<input type="email" class="form-control" name="contact[email]" autocomplete="email" required>
</div>
<div class="col-md-4 mb-3">
<label for="phone">{{ 'contact.form.phone' | t }} <i class="fa fa-asterisk"></i></label>
<input type="tel" class="form-control" name="contact[phone]" autocomplete="tel" required>
</div>
</div>
<div class="mb-3">
<label for="message">{{ 'contact.form.message' | t }}</label>
<textarea id="message" class="form-control" name="contact[body]" rows="7"></textarea>
</div>
<!-- 2019 Honeypot / Checkbox Placeholder -->
<div class="checkbox captcha"><input type="text" class="honeypot" autocomplete="off" style="display:none;"></div>
<script type="application/json" class="form-alerts">
{
"error": "{{ 'general.forms.post_error' | t }}",
"success": "{{ 'contact.form.post_success' | t }}"
}
</script>
<div class="d-none" data-alert="status"></div>
<button type="button" class="btn btn-lg btn-outline-primary btn-submit disabled" disabled>{{ 'contact.form.send' | t }}</button>
{%- endform -%}
@superfein
Copy link

Hi @callaginn, hope you're doing swell! I came across your AJAX solution and tried it out, but couldn't get it to work. Any idea if this solution still works with the latest version of Shopify Storefront?

@callaginn
Copy link
Author

callaginn commented May 17, 2022

@superfein Yep! I just tested it on the site we implemented a similar script on. However, that script was about 10 days newer. Just updated the code above.

If that update doesn't work, make sure Shopify's captcha isn't enabled. At one point, I had to tell them to turn it off, because it was redirecting to the captcha verification page (which we can't verify). You "might" be able to turn that off via the admin now.

Basically, the trick to submitting forms via ajax on Shopify are turning off their captcha and submitting it as a url encoded string, instead of a json string.

@callaginn
Copy link
Author

Ohhh, I see what could've been confusing on your end. I was capturing the entire form (form-contact.liquid) to a variable and changing the "action" to a "data-action" to try to trick bots. We have a custom captcha that flips that back after it's validated.

I've switched form-contact.liquid to use more standard form properties. Also swapped out some of the inside of it with our latest code.

@superfein
Copy link

Hey @callaginn, this is incredibly generous of you to put in the time to update this for me! I really appreciate it. :) I'm always amazed at the developer community and how much it gives back, you're definitely part of that. Thanks again!

@anhtuan9622
Copy link

hi @callaginn, could you help create the Shopify Ajax newsletter form too? Thank you!

@know-nothing-john-snow
Copy link

Hey @callaginn

I am using your awesome script, but I end up receiving this error 429 after a couple of tries, then I tried again the next day and it doesn't seem to work. I turned off the Captcha in Shopify Preferences. Any help is appreciated.

@samuelkobe
Copy link

samuelkobe commented Aug 13, 2023

@know-nothing-john-snow Hey friend, the Captcha settings don't help you fully. I just talked with support and they can't help me. They told me I need to talk with Google' support. What you can do to avoid the 429 error is ensure your form only fires once on submission.

The lines below:

console.log(response);
console.log(response.status);

Will output a response and if it is successful you will see it is either doing it correctly and submitting or it is sending you to your-url.exmaple/challenge which is where the captcha gets involved.

Using a custom-contact.liquid file and having alpine.js running I was able to create a custom pop up contact form. It still triggers the challenge if you try too many times. But using a VPN I was able to confirm that it works the first few times. I don't anticipate users to spam it unless they are bots. Additionally I have a backup hidden contact page with the default form that is linked to from the pop up form.

I used a wrapping div with the Alpine directive x-data, with the form inside like so:

<div x-data="{
  submitting: false,
  showAlert: false,
  alertMessage: '',
  formSubmitted: false,
  submitForm() {
    if (this.submitting) {
      // Form is already being submitted, prevent multiple submissions
      return;
    }

    this.submitting = true;
    let formData = new FormData($refs.submit_contact_custom_form);
    console.log('submitting form', formData);

    // Introduce a delay before form submission (e.g., 1000 milliseconds = 1 second)
    setTimeout(() => {
      fetch('/contact', {
        method: 'POST',
        body: formData
      })
      .then(response => {
        console.log(response);
        console.log(response.status);
        if (response.status === 200) {
          // Successful response
          this.formSubmitted = true;
          this.showAlert = true;
          this.alertMessage = 'Your message was successfully sent.';
          this.$refs.submit_contact_custom_form.reset() // Reset the form inputs
        } else {
          // Non-successful response
          throw new Error('Form submission failed.');
        }
      })
      .catch(error => {
        // Handle errors here
        console.error('Form submission error:', error);
        this.showAlert = true;
        this.alertMessage = 'An error occurred while submitting the form.';
        this.formSubmitted = false; // Reset formSubmitted if there was an error
      })
      .finally(() => {
        // Reset the submitting status after form submission
        this.submitting = false;
      });
    }, 1000); // 1000 milliseconds = 1 second
  }
}">

  {%- form 'contact', id: 'ContactFormCustom', class: 'bg-white', x-ref: 'submit_contact_custom_form' -%}
    <div class="field">
      <input
        class="field__input"
        autocomplete="given-name"
        type="text"
        id="ContactForm-first_name"
        name="contact[{{ 'templates.contact_custom.form.firstname' | t }}]"
        value="{% if form.first_name %}{{ form.first_name }}{% elsif customer %}{{ customer.first_name }}{% endif %}"
        placeholder="{{ 'templates.contact_custom.form.firstname' | t }}"
      >
      <label class="field__label" for="ContactForm-first_name">{{ 'templates.contact_custom.form.firstname' | t }}</label>
    </div>
    <div class="field">
      <input
        class="field__input"
        autocomplete="family-name"
        type="text"
        id="ContactForm-last_name"
        name="contact[{{ 'templates.contact_custom.form.lastname' | t }}]"
        value="{% if form.last_name %}{{ form.last_name }}{% elsif customer %}{{ customer.last_name }}{% endif %}"
        placeholder="{{ 'templates.contact_custom.form.lastname' | t }}"
      >
      <label class="field__label" for="ContactForm-last_name">{{ 'templates.contact_custom.form.lastname' | t }}</label>
    </div>
    <div class="field field--with-error">
      <input
        autocomplete="email"
        type="email"
        id="ContactForm-email"
        class="field__input"
        name="contact[email]"
        spellcheck="false"
        autocapitalize="off"
        value="{% if form.email %}{{ form.email }}{% elsif customer %}{{ customer.email }}{% endif %}"
        aria-required="true"
        {% if form.errors contains 'email' %}
          aria-invalid="true"
          aria-describedby="ContactForm-email-error"
        {% endif %}
        placeholder="{{ 'templates.contact_custom.form.email' | t }}"
      >
      <label class="field__label" for="ContactForm-email">
        {{- 'templates.contact_custom.form.email' | t }}
        <span aria-hidden="true">*</span></label
      >
      {%- if form.errors contains 'email' -%}
        <small class="contact__field-error" id="ContactForm-email-error">
          <span class="visually-hidden">{{ 'accessibility.error' | t }}</span>
          <span class="form__message">
            {%- render 'icon-error' -%}
            {{- form.errors.translated_fields.email | capitalize }}
            {{ form.errors.messages.email -}}
          </span>
        </small>
      {%- endif -%}
    </div>

    <div class="field">
      <input
        type="tel"
        id="ContactForm-phone"
        class="field__input"
        autocomplete="tel"
        name="contact[{{ 'templates.contact_custom.form.phone' | t }}]"
        pattern="[0-9\-]*"
        value="{% if form.phone %}{{ form.phone }}{% elsif customer %}{{ customer.phone }}{% endif %}"
        placeholder="{{ 'templates.contact_custom.form.phone' | t }}"
      >
      <label class="field__label" for="ContactForm-phone">{{ 'templates.contact_custom.form.phone' | t }}</label>
    </div>
    <div class="field">
      <textarea
        rows="10"
        id="ContactForm-body"
        class="text-area field__input"
        name="contact[{{ 'templates.contact_custom.form.comment' | t }}]"
        placeholder="{{ 'templates.contact_custom.form.comment' | t }}"
      >
        {{- form.body -}}
      </textarea>
      <label class="form__label field__label" for="ContactForm-body">
        {{- 'templates.contact_custom.form.comment' | t -}}
      </label>
    </div>

    <div x-show="!formSubmitted">
      <button type="button" class="primary_button alt" @click="submitForm()" x-bind:disabled="submitting">{{ 'templates.contact_custom.form.send' | t }}</button>
    </div>

    {%- if form.posted_successfully? -%}
      <h2 class="form-status form-status-list form__message" tabindex="-1" autofocus>
        {% render 'icon-success' %}
        {{ 'templates.contact_custom.form.post_success' | t }}
      </h2>
    {%- elsif form.errors -%}
      <div class="form__message">
        <h2 class="form-status caption-large text-body" role="alert" tabindex="-1" autofocus>
          {% render 'icon-error' %}
          {{ 'templates.contact_custom.form.error_heading' | t }}
        </h2>
      </div>
      <ul class="form-status-list caption-large" role="list">
        <li>
          <a href="#ContactForm-email" class="link">
            {{ form.errors.translated_fields.email | capitalize }}
            {{ form.errors.messages.email }}
          </a>
        </li>
      </ul>
    {%- endif -%}

  {%- endform -%}

  <div x-show="showAlert" x-text="alertMessage" x-bind:class="{'success-message text-primary-main_dark': formSubmitted, 'error-message': !formSubmitted}"></div>
</div>

{% schema %}
{
  "name": "Custom Contact Form",
  "tag": "section",
  "class": "section",
  "settings": [],
  "presets": [
    {
      "name": "Custom Contact Form"
    }
  ]
}
{% endschema %}

Hope you find some success.

@obaidhossain
Copy link

obaidhossain commented Sep 21, 2023

@callaginn Do you have any idea, if we can overwrite the reCaptcha with reCAPTCHA v3 to skip the redirection/challenge?

@JsChiSurf
Copy link

JsChiSurf commented Nov 28, 2023

Thanks for the script. Has anybody have an luck with this recently?

I have a sandbox store that I've been playing around with and after some slight modification to the code to get "mostly" working in my environment (the stock functions still bind to the form, even with event.preventDefault(), so the custom Ajax code essentially doesn't run for me anyway. I circumvented this by cloning the form at page load and then binding this Ajax library instead.

This is all great, and prevents any sort of default page redirection, etc, with the hopes of just staying on the page from where the form is submitted (for me this is a modal / dialog). However, no matter what I do, I'm getting a HTTP error '429' on the attempted post once turning the captcha off in the store preferences.

If I turn captcha back on, it forces the redirect even when posting via AJAX thus defeating the point.

What's even more crazy is with captcha turned off, the default contact form (separate template) still issues the captcha. What's the point?

This would makes sense though that while I have captcha turned off in the store I am still getting the HTTP 429 error when attempting to submit via the ajax since it seems clear captcha is still being enforced.

I have not reached out to Shopify yet, but in reading the posts here, it seems like they have no interest in helping fully disable despite the 'settings' being turned off.

EDIT: I can confirm as well that going in over VPN and switching between various locations the 429 status code does not initially occur. I guess it is just a matter of what the "rate limit" is, but I feel like there is too much risk in "hoping" this won't be encountered in a production environment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment