Skip to content

Instantly share code, notes, and snippets.

@elghorfi
Last active December 27, 2024 07:25
Show Gist options
  • Save elghorfi/ce7e5b1080aae37d0a415643f33bc79e to your computer and use it in GitHub Desktop.
Save elghorfi/ce7e5b1080aae37d0a415643f33bc79e to your computer and use it in GitHub Desktop.
{%comment%}
#############################################
# Mohamed El-Ghorfi Discount Code on Cart #
# [UPDATED] #
#############################################
# Paypal Me: https://paypal.me/elghorfimed #
# Buy Me A Coffee: #
# https://www.buymeacoffee.com/elghorfi #
#############################################
# [email protected] #
#############################################
{%endcomment%}
<style>
.cart-sidebar-discount {
display: flex;
flex-direction: column;
width:300px;
margin: 20px auto;
}
.cart-sidebar-discount input {
margin-top:20px;
background: #eee;
border: 1px solid #eee;
height:50px;
outline: none;
font-size: 18px;
letter-spacing: .75px;
text-align: center;
}
#apply-discount-btn {
background-color: #000;
color:#fff;
border: 0;
height: 60px;
width: 100%;
font-size: 18px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .75px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
span.applied-discount-code-value>small {
background: #eee;
padding: 0px 10px;
color: #000;
font-weight: bold;
border-radius: 20px;
}
.loader {
border: 5px solid #f3f3f3;
border-top: 4px solid #000;
border-radius: 50%;
width: 25px;
height: 25px;
animation: spin .5s linear infinite;
}
#discount-code-error {
background: #ff00004f;
color: #e22120;
padding: 5px;
border-radius: 4px;
font-size: 13px;
line-height: 1;
}
.applied-discount-code-wrapper {
display: none;
background: #ddd;
padding: 3px 6px;
border-radius: 25px;
}
.applied-discount-code-value {
font-size: 13px;
text-transform: uppercase;
}
#discount-code-error:empty {
display: none;
}
.applied-discount-code-value:empty+button {
display: none;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div class="cart-sidebar-discount">
<span id="applied-discount-code">
Discount Code:
<span class="applied-discount-code-wrapper">
<span class="applied-discount-code-value"></span>
<button id="clear-discount-btn">X</button>
</span>
</span>
<small id="discount-code-error"></small>
<input type="text" id="discount-code-input" autocomplete="on" value="">
<button id="apply-discount-btn">APPLY</button>
</div>
<script>
document.addEventListener("DOMContentLoaded", function(event) {
let clearBtn = document.querySelector("#clear-discount-btn");
let applyBtn = document.querySelector("#apply-discount-btn");
let discountCodeError = document.querySelector("#discount-code-error");
let discountCodeWrapper = document.querySelector("#applied-discount-code .applied-discount-code-wrapper");
let discountCodeValue = document.querySelector("#applied-discount-code .applied-discount-code-value");
let discountCodeInput = document.querySelector("#discount-code-input");
let totalCartSelector = document.querySelector(".cart__subtotal .money"); // Total Cart Selector to update the total amount.
let authorization_token;
let checkoutContainer = document.createElement('div');
document.body.appendChild(checkoutContainer);
if (localStorage.discountCode) applyDiscount( JSON.parse(localStorage.discountCode).code);
if(applyBtn)
applyBtn.addEventListener("click", function(e){
e.preventDefault()
applyDiscount(discountCodeInput.value);
});
if(clearBtn)
clearBtn.addEventListener("click", function(e){
e.preventDefault()
clearDiscount();
});
function clearDiscount() {
discountCodeValue.innerHTML = "";
discountCodeError.innerHTML = "";
clearLocalStorage();
fetch("/discount/CLEAR");
}
function clearLocalStorage() {
if(discountCodeWrapper) discountCodeWrapper.style.display = "none";
if(totalCartSelector) totalCartSelector.innerHTML = JSON.parse(localStorage.discountCode).totalCart;
localStorage.removeItem("discountCode");
}
function applyDiscount(code) {
if(applyBtn) {
applyBtn.innerHTML = "APPLYING <div class='loader'></div>";
applyBtn.style.pointerEvents = "none";
}
fetch("/payments/config", {"method": "GET"})
.then(function(response) { return response.json() })
.then(function(data) {
const checkout_json_url = '/wallets/checkouts/';
authorization_token = btoa(data.paymentInstruments.accessToken)
fetch('/cart.js', {}).then(function(res){return res.json();})
.then(function(data){
let body = {"checkout": { "country": Shopify.country,"discount_code": code,"line_items": data.items, 'presentment_currency': Shopify.currency.active } }
fetch(checkout_json_url, {
"headers": {
"accept": "*/*", "cache-control": "no-cache",
"authorization": "Basic " + authorization_token,
"content-type": "application/json, text/javascript",
"pragma": "no-cache", "sec-fetch-dest": "empty",
"sec-fetch-mode": "cors", "sec-fetch-site": "same-origin"
},
"referrerPolicy": "strict-origin-when-cross-origin",
"method": "POST", "mode": "cors", "credentials": "include",
"body": JSON.stringify(body)
})
.then(function(response) { return response.json() })
.then(function(data) {
console.log(data.checkout);
if(data.checkout && data.checkout.applied_discounts.length > 0){
let discountApplyUrl = "/discount/"+code+"?v="+Date.now()+"&redirect=/checkout/";
fetch(discountApplyUrl, {}).then(function(response) { return response.text(); })
if(discountCodeWrapper) discountCodeWrapper.style.display = "inline";
if(discountCodeError) discountCodeError.innerHTML = "";
if(discountCodeValue) discountCodeValue.innerHTML = data.checkout.applied_discounts[0].title + " (" + data.checkout.applied_discounts[0].amount + ' ' + Shopify.currency.active + ")";
let localStorageValue = {
'code': code.trim(),
'totalCart': data.checkout.total_line_items_price
};
localStorage.setItem("discountCode", JSON.stringify(localStorageValue));
if(totalCartSelector) totalCartSelector.innerHTML = "<s>" + data.checkout.total_line_items_price + "</s>" + data.checkout.total_price;
}else{
if(discountCodeValue) discountCodeValue.innerHTML = "";
clearLocalStorage();
if(discountCodeError) discountCodeError.innerHTML = "Please Enter Valid Coupon Code."
}
}).finally(function(params) {
if(applyBtn){
applyBtn.innerHTML = "APPLY";
applyBtn.style.pointerEvents = "all";
}
});
});
});
}
});
</script>
@ddegroot1985
Copy link

Great piece of work (but I think less is more). I do it with a more simple approach but I'm curious what could be some advantages of the method exposed here?

I will explain my way in a super simplified way. This is an html cart (inside a section):

{% assign is_manual_discount_applied = cart.discount_applications | where: 'type', 'discount_code' %}

<cart-foo {% if is_manual_discount_applied != blank %}manual-discount{% endif %}>
   ...
  {%- for discount_application in cart.discount_applications -%}
     {{ discount_application.title }}: − {{ discount_application.total_allocated_amount | money }}
  {%- endfor -%}
   ...
</cart-foo>

And for the JS code:

  1. Call /discount API with code variable
  2. Fetch and replace the html cart dynamically
  3. Check if attribute manual-discount exist in the new cart
  4. The manual-discount attribute:
    - Exist: means the code was applied, job done!
    - Do not exist: means the code is not valid (you can show an error message)
fetch(`/discount/${input.value}`).then(async () => {
   // update cart
   const data = await fetch(`/cart?section_id=${sectionId}`).then(response => response.text());
   const newCart = new DOMParser().parseFromString(data, 'text/html').querySelector('cart-foo');
   document.querySelector('cart-foo').replaceWith(newCart);
  
  // error handling if manual discount is not applied
  if (!document.querySelector('cart-foo').hasAttribute('manual-discount')) {
    ...
  }
});

Obviously it's over simplified, I added some complexities for myself but that's the gist of it, all in a single call and 100% compatible with liquid discount_allocation and discount_application objects. One advantage I could see with @elghorfi way is that you could access the exact error message in case it fails. But even in my case, I could output two types of errors:

  • Discount code does not exist or isn't valid for the items in your cart
  • Discount code does not exist or could not be used with your existing (automatic) discounts.

Let me know your thoughts, and really I'm curious if those endpoints (/payments/config and /wallets/checkout) really bring more value here?

This method looks a lot cleaner but how do you detect different types of errors?

@MaxDesignFR
Copy link

MaxDesignFR commented Apr 16, 2024

@ddegroot1985 I believe there is still room for improvement in my own code. First of all, we could use /checkout/code1,code2,... instead of /discount/code1 to be able to handle multiple discounts and not erase currently applied manual discounts.

For the error handling, my way is more of a workaround than anything else... You see in 4., I check if a manual discount is applied. If yes, it (most likely) worked, if not, you can display 2 types of errors, either:

  • if {{ cart.total_discount > 0 }}: [code] discount code does not exist or could not be used with your existing discounts.
  • else: [code] discount code does not exist or isn't valid for the items in your cart.

That's not perfect by any mean, but it's not easy to get it right without any documentation. I've also started exploring /wallets/checkouts/ which is most likely the way to go, here's some code I've been playing around with, but haven't implemented into a full working solution yet, I believe this could be used in combination with /checkout/code1,code2,.... Feel free to share if you come up with more:

async playgroud() {
   const discount = 'code1';
   const shopify_features_script = document.querySelector("script[id='shopify-features']");
   const shopify_features_json = JSON.parse(shopify_features_script.innerHTML);
   const cart = await fetch(`${Shopify.routes.root}cart.js`).then(response => response.json());
   const headers = {
      Authorization: 'Basic ' + btoa(shopify_features_json.accessToken),
      Accept: '*/*',
      'Content-Type': 'application/json',
    };
    const body = {
      checkout: {
        line_items: cart.items,
        discount_code: discount,
        country: Shopify.country,
        presentment_currency: cart.currency,
      },
    };
    const wallet = await fetch('/wallets/checkouts/', {
      method: 'POST',
      headers: headers,
      body: JSON.stringify(body),
      referrerPolicy: 'no-referrer',
    });
    const response = await wallet.json();
    console.log('🚀 ~ response:', response);
}

@ddegroot1985
Copy link

@MaxDesignFR Thank you for those insights, I have been playing with it this past two days and believe I have a pretty decent solution. It is not 100% bullet proof yet. As far as I can tell the /wallets/checkouts/ endpoint seems some sort of pre-flight validation check to see if the discount code can be applied. Unfortunately as far as I can tell this only works for single discounts, it does not return any errors when trying to apply a discount that does not stack with an already applied discount.

Applying multiple discounts is possible using fetch(`${Shopify.routes.root}checkout?discount=${[newDiscountCode, ...alreadyAppliedDiscounts].join(',')}&=${Date.now()}`);

This seems to never return any errors, even when no discounts are applied so you still have to check if the discount is actually applied. So far the best way I found is by creating a application/json script tag in the html when re-rendering the section, parsing that and cross referencing that with the code that was just added.

@MaxDesignFR
Copy link

MaxDesignFR commented Apr 17, 2024

As far as I can tell the /wallets/checkouts/ endpoint seems some sort of pre-flight validation check to see if the discount code can be applied

Something like that. In my testing, it seems it can be more than that though. For instance, you could verify a shipping discount with /wallets/checkouts/ to pre-apply it in cart page, whereas /checkout/ won't let you know if this shipping discount even exist (since shipping discount can't be outputed with liquid, while order and product discounts can). Less popular, but nevertheless can't be ignored.

I also have not tried gift card codes. Those are technically processed as payment methods and not discount codes, so they can't be outputed with liquid in cart page. If /wallets/checkouts/ can verify those, then it's one more thing /checkout/ can't do. I have no idea, but that's something worth checking.

And lastly, I have sometimes noticed issue where fetching /checkout/ + re-rendering cart section can be prone to error when called multiple times in a row. My guess is that Shopify starts caching the cart section after a while, and at some point it will not be in sync with newly added or removed discounts, it's confusing when it happens. To edge against this scenario, the cart could be updated (line item quantity, random cart attribute or cart note). I have not checked if fetching /wallets/checkouts/ before fetching /checkout/ avoids this caching issue.

Those are a few things I have noticed and still need to investigate more, let me know if you find out more, it's a tedious process for sure ;)

@ddegroot1985 what's the reason for this parameter &=${Date.now()}?

@pramathesh
Copy link

This worked flawlessly for me on Dawn 14.0.0. However, the cart amount is not updated to factor the coupon discount unless the page is refreshed, any workaround for that? Same for when the coupon is deleted, the cart value still shows the discounted amount until the page is refreshed.

@esangcap
Copy link

esangcap commented Jul 1, 2024

Hi @elghorfi This works perfectly fine on our store but the problem is we lately had an issue on mobile device. When user inputs discount code and clicks apply, nothing happens. This only happens on mobile devices, but works fine on desktop. It's so weird. Please help

@elghorfi
Copy link
Author

elghorfi commented Jul 1, 2024

hey everyone,

Thank you for contributing to this.
If you need any assistance, please reach out to me, so I can help you with that,

@OS4
Copy link

OS4 commented Jul 9, 2024

This works partially with Dawn 14. The code is applied, but I see 403 being returned by the call to /cart/change and /checkouts/cn/Z2NwLWV1cm9wZS13ZXN0MTowMUoyQkZGWjlCODZSRVpXVFE5NzNFR1lNRA?v=1720531825116.

The coupon removal doesn't work at all and a 403 is seen here as well, /checkouts/cn/Z2NwLWV1cm9wZS13ZXN0MTowMUoyQkZGWjlCODZSRVpXVFE5NzNFR1lNRA?discount=

Also, the coupon keeps coming back. Go to checkout and remove the coupon. Click on the cart icon, no coupon. Refresh the page and the coupon comes back.

And the coupon can take up to 3 or 4 attempts to finally stick!

@ThomasB-Sunology
Copy link

ThomasB-Sunology commented Jul 22, 2024

@OS4 I found a solution for the coupon removal :

Replace :
fetch("/checkout?discount=%20");

By this :
fetch("/discount/CLEAR");

@anuragku77
Copy link

To see the changes u have to refresh page eveytime can anyone have solved this issues.can someone please guide for this problem

@elghorfi
Copy link
Author

@ThomasB-Sunology Thank you for the suggestion, I've updated the gist.
@anuragku77 you need to adapt the script to match your cart page layout/code

@elghorfi
Copy link
Author

As a heads-up, working to integrate the new multi-discounts on the above script so you can combine discount codes.
If you have any suggestions, you can reach out. Or leave a comment below.

@Raysbaelo
Copy link

@ThomasB-Sunology Thanks for your contribution! Do you have a link to the documentation you are using for : fetch("/discount/CLEAR");

ps: it works for me :)

@ThomasB-Sunology
Copy link

@Raysbaelo Actually I found this blog article. It's not always CLEAR we need to pass a non-existing discount code :

https://community.shopify.com/c/technical-q-a/how-to-fully-remove-a-discount-code-from-a-cookie-with/m-p/2514785/highlight/true#M154983

@Raysbaelo
Copy link

@ThomasB-Sunology Cool, that's an interesting way to delete a discount code, that means if my code has been made for multiple discount codes it is not viable anymore, I guess.. ( depends on the code structure ). Does anyone have a functional code for a combination of discount codes without weird behavior when applying/deleting a code?

@geranuda
Copy link

geranuda commented Aug 24, 2024

This is great! Thank you so much for the help, I managed to make this with Claude and it is fully functional for multiple codes:

document.addEventListener("DOMContentLoaded", function(event) { 
  let clearBtn = document.querySelector("#clear-discount-btn");
  let applyBtn = document.querySelector("#apply-discount-btn");
  let discountCodeError = document.querySelector("#discount-code-error");
  let discountCodeWrapper = document.querySelector("#applied-discount-code .applied-discount-code-wrapper");
  let discountCodeValue = document.querySelector("#applied-discount-code .applied-discount-code-value");
  let discountCodeInput = document.querySelector("#discount-code-input");
  let totalCartSelector = document.querySelector(".cart__subtotal .money");
  
  let appliedDiscounts = JSON.parse(localStorage.getItem("appliedDiscounts") || "[]");

  if (typeof customerHasLowestPriceTag !== 'undefined' && customerHasLowestPriceTag) {
    applyAutomaticDiscounts(['custom-code-1', 'custom-code-2', 'custom-code-3', ... more codes]);
  }

  if (clearBtn)
    clearBtn.addEventListener("click", function(e){
      e.preventDefault();
      clearDiscount();
    });

  if (applyBtn)
    applyBtn.addEventListener("click", function(e){
      e.preventDefault();
      applyDiscount(discountCodeInput.value);
    });

  function clearDiscount() {
    appliedDiscounts = [];
    localStorage.setItem("appliedDiscounts", JSON.stringify(appliedDiscounts));
    discountCodeValue.innerHTML = "";
    discountCodeError.innerHTML = "";
    fetch(`${Shopify.routes.root}checkout/clear`);
    location.reload();
  }

  function applyAutomaticDiscounts(discountCodes) {
    discountCodes.forEach(code => applyDiscount(code));
  }

  async function applyDiscount(code) {
    if(applyBtn) {
      applyBtn.innerHTML = "APPLYING <div class='loader'></div>";
      applyBtn.style.pointerEvents = "none";
    }

    try {
      // First, validate the new discount code
      const validationResponse = await validateDiscountCode(code);
      if (!validationResponse.isValid) {
        throw new Error(validationResponse.error);
      }

      // If valid, add to applied discounts and apply all
      if (!appliedDiscounts.includes(code)) {
        appliedDiscounts.push(code);
      }
      
      const applyResponse = await applyDiscounts(appliedDiscounts);
      if (applyResponse.success) {
        updateUI(applyResponse.discounts);
        localStorage.setItem("appliedDiscounts", JSON.stringify(appliedDiscounts));
      } else {
        throw new Error(applyResponse.error);
      }

    } catch (error) {
      console.error('Error:', error);
      if(discountCodeError) discountCodeError.innerHTML = error.message;
    } finally {
      if(applyBtn) {
        applyBtn.innerHTML = "APPLY";
        applyBtn.style.pointerEvents = "all";
      }
    }
  }

  async function validateDiscountCode(code) {
    const shopify_features_script = document.querySelector("script[id='shopify-features']");
    const shopify_features_json = JSON.parse(shopify_features_script.innerHTML);
    const cart = await fetch(`${Shopify.routes.root}cart.js`).then(response => response.json());
    
    const headers = {
      Authorization: 'Basic ' + btoa(shopify_features_json.accessToken),
      Accept: '*/*',
      'Content-Type': 'application/json',
    };

    const body = {
      checkout: {
        line_items: cart.items,
        discount_code: code,
        country: Shopify.country,
        presentment_currency: cart.currency,
      },
    };

    const response = await fetch('/wallets/checkouts/', {
      method: 'POST',
      headers: headers,
      body: JSON.stringify(body),
      referrerPolicy: 'no-referrer',
    });

    const data = await response.json();

    if (data.errors && data.errors.discount_code) {
      return { isValid: false, error: data.errors.discount_code[0].message };
    }

    return { isValid: true };
  }

  async function applyDiscounts(discountCodes) {
    const response = await fetch(`${Shopify.routes.root}checkout?discount=${discountCodes.join(',')}&_=${Date.now()}`);
    const html = await response.text();
    const parser = new DOMParser();
    const doc = parser.parseFromString(html, 'text/html');
    const discountScript = doc.querySelector('script[data-discount-allocations]');
    
    if (discountScript) {
      const discountData = JSON.parse(discountScript.textContent);
      return { success: true, discounts: discountData };
    } else {
      return { success: false, error: "Failed to apply discount" };
    }
  }

  function updateUI(discounts) {
    if(discountCodeWrapper) discountCodeWrapper.style.display = "inline";
    if(discountCodeError) discountCodeError.innerHTML = "";
    if(discountCodeValue) {
      discountCodeValue.innerHTML = discounts.map(discount => 
        `${discount.title} (${formatMoney(discount.amount)})`
      ).join(', ');
    }
    
    // update total if needed
    //might need to adjust this based on how your cart data is structured
    if(totalCartSelector) {
      const totalPrice = discounts.reduce((total, discount) => total - discount.amount, cart.total_price);
      totalCartSelector.innerHTML = `<s>${formatMoney(cart.total_price)}</s> ${formatMoney(totalPrice)}`;
    }

    // Refresh the cart to reflect changes
    location.reload();
  }

  function formatMoney(cents) {
    return (cents / 100).toLocaleString('en-US', {
      style: 'currency',
      currency: Shopify.currency.active
    });
  }
});

@procarrera
Copy link

procarrera commented Sep 17, 2024

@MaxDesignFR Thank you for those insights, I have been playing with it this past two days and believe I have a pretty decent solution. It is not 100% bullet proof yet. As far as I can tell the /wallets/checkouts/ endpoint seems some sort of pre-flight validation check to see if the discount code can be applied. Unfortunately as far as I can tell this only works for single discounts, it does not return any errors when trying to apply a discount that does not stack with an already applied discount.

Applying multiple discounts is possible using fetch(`${Shopify.routes.root}checkout?discount=${[newDiscountCode, ...alreadyAppliedDiscounts].join(',')}&=${Date.now()}`);

This seems to never return any errors, even when no discounts are applied so you still have to check if the discount is actually applied. So far the best way I found is by creating a application/json script tag in the html when re-rendering the section, parsing that and cross referencing that with the code that was just added.

Intresting, it did applied multiple coupons, however, the first call returned 302 and a subsequent call to checkout returned 403

@MaxDesignFR
Copy link

Did anyone notice this error when fetching /checkout?discount=HELLO10 on iOS platform when ShopPay is enabled?

Fetch API cannot load https://shop.app/checkout/62371266751/cn/Z2NwLXVzLWVhc3QxOjAxSjg3NkY2MFhFNkFXVjJIRDkwMThXU1Ew/shop_pay_callback?discount_code=HELLO10&locale=fr-NL&.....[immense string].... due to access control checks. TypeError: Load failed

It works fine on Android, but seemingly fetching /checkout when ShopPay is enabled on an iOS device generate this error. Curious if someone can replicate this issue, looking for solutions right now (damn iOS, always something!)

@JanIshkaster
Copy link

Thank you, kind Sir.

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