Discuss and resolve questions on Liquid, JavaScript, themes, sales channels, and site speed enhancements.
As the title suggests, I'm rather disappointed that there's no means to add gift wrapping to a product without a 3rd party plugin. This is not going to happen, so I've been trying to add my own script to the Dawn 11 theme. I took a hint on accessing a giftwrap product via a menu from a snippet that I found on the Internet. However, I wanted this script to be applicable per product and be aware of variants so that different messages / wrapping could be added for different versions of the same product, and the Internet script was very basic (only messing with the basket page).
I wrote some code for what I'm trying to achieve:
{% assign giftwrap_id = linklists['gift-wrapping'].links.first.object.variants.first.id %}
<script>
document.addEventListener("DOMContentLoaded", function() {
var giftWrapCheck = document.getElementById('GiftWrapping-{{ section_id }}');
var giftWrapMessage = document.getElementById('GiftWrappingMessage-{{ section_id }}');
var quantityInput = document.getElementById('Quantity-{{ section_id }}');
var submitButton = document.getElementById('ProductSubmitButton-{{ section_id }}');
var productVariantAccess = document.getElementById('productVariantAccess');
// Only show the gift-wrap message if the customer wants the product gift-wrapping
giftWrapCheck.addEventListener("click", function(event) {
if (giftWrapCheck.checked) {
giftWrapMessage.style.display = 'block';
} else {
giftWrapMessage.style.display = 'none';
}
});
// Add event listeners to the buy buttons
submitButton.addEventListener("click", handleAddGiftWrap);
waitForElement('button[data-testid="Checkout-button"]').then((buyNowButton) => {
buyNowButton.addEventListener("click", handleAddGiftWrap);
});
// Adds gift-wrap to a specific product if selected
function handleAddGiftWrap(event) {
if (giftWrapCheck.checked) {
var request = {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
items: [
{
id: {{ giftwrap_id }},
quantity: parseInt(quantityInput.value),
properties: {
Item: '{{ product.title | escape }}',
"Buying Option": productVariantAccess.value,
Message: giftWrapMessage.value,
}
}
]
})
};
setTimeout(function() {
fetch('/cart/add.js', request).then(response => {
giftWrapCheck.checked = false;
giftWrapMessage.style.display = 'none';
});
}, 600);
}
}
// Waits for an element (e.g. button) to be rendered
function waitForElement(selector) {
return new Promise(resolve => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(mutations => {
if (document.querySelector(selector)) {
resolve(document.querySelector(selector));
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
});
</script>
<div class="gift-wrap-product">
<input id="GiftWrapping-{{ section_id }}" type="checkbox" name="gift-wrapping" value="1" />
<label for="GiftWrapping-{{ section_id }}">Giftwrap for {{ linklists['gift-wrapping'].links.first.object.price | money }} per item?</label>
<textarea id="GiftWrappingMessage-{{ section_id }}" style="display: none;" placeholder="Please type a short gift message here (optional)..."></textarea>
</div>
Which is called by rendering it in the `buy-buttons.liquid` file:
{% render 'gift-wrap-product', section_id: section_id %}
This basically works (at least for the "Add to Basket" button). However, it ONLY seems to work when I wrap the API call in a setTimeout block!? This is problematic for the "Buy Now" button.
setTimeout(function() {
fetch('/cart/add.js', request).then(response => {
giftWrapCheck.checked = false;
giftWrapMessage.style.display = 'none';
});
}, 600);
This is obviously hacky, but when I remove the timeout it either never gets added or only adds the giftwrap and not the product!? Not knowing anything about the underlying code I don't know what's going on here? Some form of locking mechanism? This is my first time using Shopify, so the theme code is pretty overwhelming and I really didn't want to start hacking about with the bits that already work well!
What should I be doing to make this principle work reliably? Any help or suggestions are appreciated.
I've tried a second approach to this and it seems to be more stable. This did require modifying (and half ignoring) existing theme code implementation. It still requires creating a menu (named: `gift-wrapping`) in the shop UI and adding in a sole item containing the gift-wrapping product.
It then starts the same way, rendering some gift-wrap code in `buy-buttons.liquid`:
{% render 'gift-wrap-product', section_id: section_id %}
This `gift-wrap-product.liquid` file required re-writing and since I couldn't figure out where the "Buy Now" button was being powered from, I just chose to make my own custom one of those (using cloneNode) so that I could totally ignore the theme events:
<script>
document.addEventListener('DOMContentLoaded', function() {
var giftwrap_id = document.getElementById('giftwrap_id').value;
var giftwrap_checkbox = document.getElementById('giftwrap_checkbox');
var giftwrap_message = document.getElementById('giftwrap_message');
giftwrap_checkbox.addEventListener("click", function(event) {
if (giftwrap_checkbox.checked) {
giftwrap_message.style.display = 'block';
} else {
giftwrap_message.style.display = 'none';
}
});
waitForElement('button[data-testid="Checkout-button"]').then((buyNowButton) => {
var parent = buyNowButton.parentNode;
var replacementBuyNowButton = buyNowButton.cloneNode(true);
parent.insertBefore(replacementBuyNowButton, buyNowButton);
buyNowButton.remove();
replacementBuyNowButton.addEventListener('click', function(event) {
event.preventDefault();
var product_id = document.querySelector('input[name="id"]').value;
var product_title = document.getElementById('product_title').value;
var product_variant = document.getElementById('productVariantAccess').value;
var quantity = parseInt(document.querySelector('input[name="quantity"]').value);
var body = {
items: [
{
id: product_id,
quantity: quantity,
}
],
}
if (giftwrap_checkbox.checked) {
body.items.push({
id: giftwrap_id,
quantity: quantity,
properties: {
Item: product_title,
"Buying Option": product_variant,
Message: giftwrap_message.value,
}
});
}
var request = {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
};
fetch('/cart/add.js', request)
.then((response) => response.json())
.then((response) => {
if (response.status) {
publish(PUB_SUB_EVENTS.cartError, {
source: 'product-form',
productVariantId: product_id,
errors: response.errors || response.description,
message: response.message,
});
handleErrorMessage(response.description);
var form = document.querySelector('product-form');
var submitButton = form.querySelector('[type="submit"]');
const soldOutMessage = submitButton.querySelector('.sold-out-message');
if (!soldOutMessage) {
return;
}
submitButton.setAttribute('aria-disabled', true);
submitButton.querySelector('span').classList.add('hidden');
soldOutMessage.classList.remove('hidden');
return;
}
})
.catch((e) => {
console.error(e);
})
.finally(() => {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/cart';
const hiddenField = document.createElement('input');
hiddenField.type = 'hidden';
hiddenField.name = "checkout";
hiddenField.value = "Check out";
form.appendChild(hiddenField);
document.body.appendChild(form);
form.submit();
});
});
});
// Port of Shopify `product-form.js` handler
function handleErrorMessage(errorMessage = false) {
var form = document.querySelector('product-form');
var errorMessageWrapper = form.querySelector('.product-form__error-message-wrapper');
if (!errorMessageWrapper) {
return;
}
var errorMessageContainer = errorMessageWrapper.querySelector('.product-form__error-message');
if (!errorMessageContainer) {
return;
}
errorMessageWrapper.toggleAttribute('hidden', !errorMessage);
if (errorMessage) {
errorMessageContainer.textContent = errorMessage;
}
}
// Waits for an element (e.g. button) to be rendered
function waitForElement(selector) {
return new Promise(resolve => {
if (document.querySelector(selector)) {
return resolve(document.querySelector(selector));
}
const observer = new MutationObserver(mutations => {
if (document.querySelector(selector)) {
resolve(document.querySelector(selector));
observer.disconnect();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
}
});
</script>
<div class="gift-wrap-product">
<input type="hidden" id="product_title" value="{{ product.title | escape }}" />
<input type="hidden" id="giftwrap_id" value="{{ linklists['gift-wrapping'].links.first.object.variants.first.id }}" />
<input id="giftwrap_checkbox" type="checkbox" value="1" />
<label for="giftwrap_checkbox">Giftwrap for {{ linklists['gift-wrapping'].links.first.object.price | money }} per item?</label>
<textarea id="giftwrap_message" style="display: none;" placeholder="Please type a short gift message here (optional)..."></textarea>
</div>
This code adds the required HTML fields for the gift-wrapping, but it also duplicates much of the code required for the "Add to Cart" button for the "Buy it Now" button. If you don't need that button, you can delete the script part leaving just the HTML. The "Buy it Now" button is basically just a POST request to the `/cart` endpoint, so that requires a bit of a hack to simulate (create a dummy form and submit it) after the API call is complete.
The "Add to Cart" button is more conventionally handled through extending the Dawn 11 theme's `product-form.js` file:
if (!customElements.get('product-form')) {
customElements.define(
'product-form',
class ProductForm extends HTMLElement {
constructor() {
super();
this.form = this.querySelector('form');
this.form.querySelector('[name=id]').disabled = false;
this.form.addEventListener('submit', this.onSubmitHandler.bind(this));
this.cart = document.querySelector('cart-notification') || document.querySelector('cart-drawer');
this.submitButton = this.querySelector('[type="submit"]');
if (document.querySelector('cart-drawer')) {
this.submitButton.setAttribute('aria-haspopup', 'dialog');
}
this.hideErrors = this.dataset.hideErrors === 'true';
}
onSubmitHandler(evt) {
evt.preventDefault();
if (this.submitButton.getAttribute('aria-disabled') === 'true') {
return;
}
this.handleErrorMessage();
this.submitButton.setAttribute('aria-disabled', true);
this.submitButton.classList.add('loading');
this.querySelector('.loading-overlay__spinner').classList.remove('hidden');
const formData = new FormData(this.form);
var product_id = formData.get('id');
var product_title = document.getElementById('product_title').value;
var product_variant = document.getElementById('productVariantAccess').value;
var quantity = parseInt(formData.get('quantity'));
var giftwrap_id = document.getElementById('giftwrap_id').value;
var giftwrap_checkbox = document.getElementById('giftwrap_checkbox');
var giftwrap_message = document.getElementById('giftwrap_message');
var body = {
items: [
{
id: product_id,
quantity: quantity,
}
],
sections: this.cart.getSectionsToRender().map((section) => section.id),
sections_url: window.location.pathname
}
if (giftwrap_checkbox.checked) {
body.items.push({
id: giftwrap_id,
quantity: quantity,
properties: {
Item: product_title,
"Buying Option": product_variant,
Message: giftwrap_message.value,
}
});
}
var request = {
method: 'POST',
headers: new Headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body)
};
if (this.cart) {
this.cart.setActiveElement(document.activeElement);
}
fetch('/cart/add.js', request)
.then((response) => response.json())
.then((response) => {
if (response.status) {
publish(PUB_SUB_EVENTS.cartError, {
source: 'product-form',
productVariantId: formData.get('id'),
errors: response.errors || response.description,
message: response.message,
});
this.handleErrorMessage(response.description);
const soldOutMessage = this.submitButton.querySelector('.sold-out-message');
if (!soldOutMessage) {
return;
}
this.submitButton.setAttribute('aria-disabled', true);
this.submitButton.querySelector('span').classList.add('hidden');
soldOutMessage.classList.remove('hidden');
this.error = true;
return;
} else if (!this.cart) {
window.location = window.routes.cart_url;
return;
}
if (!this.error) {
publish(PUB_SUB_EVENTS.cartUpdate, {
source: 'product-form',
productVariantId: formData.get('id'),
cartData: response
});
}
this.error = false;
const quickAddModal = this.closest('quick-add-modal');
if (quickAddModal) {
document.body.addEventListener(
'modalClosed',
() => {
setTimeout(() => {
this.cart.renderContents(response);
});
},
{ once: true }
);
quickAddModal.hide(true);
} else {
this.cart.renderContents(response);
}
})
.catch((e) => {
console.error(e);
})
.finally(() => {
this.submitButton.classList.remove('loading');
if (this.cart && this.cart.classList.contains('is-empty')) this.cart.classList.remove('is-empty');
if (!this.error) this.submitButton.removeAttribute('aria-disabled');
this.querySelector('.loading-overlay__spinner').classList.add('hidden');
document.getElementById('giftwrap_checkbox').checked = false;
document.getElementById('giftwrap_message').style.display = 'none';
});
}
handleErrorMessage(errorMessage = false) {
if (this.hideErrors) return;
this.errorMessageWrapper =
this.errorMessageWrapper || this.querySelector('.product-form__error-message-wrapper');
if (!this.errorMessageWrapper) return;
this.errorMessage = this.errorMessage || this.errorMessageWrapper.querySelector('.product-form__error-message');
this.errorMessageWrapper.toggleAttribute('hidden', !errorMessage);
if (errorMessage) {
this.errorMessage.textContent = errorMessage;
}
}
}
);
}
Because we can now add multiple items / quantities to the cart at once, this also requires a change to the `cart-notification.js` file, but only a couple of the functions need tampering with:
renderContents(parsedState) {
this.cartItemKeys = [parsedState.key];
if (parsedState.items) {
this.cartItemKeys = [];
parsedState.items.forEach(item => {
this.cartItemKeys.push(item.key)
});
}
this.getSectionsToRender().forEach((section) => {
var output = '';
if (section.selector) {
this.cartItemKeys.forEach(key => {
var selector = undefined;
if (section.selector) {
selector = section.selector.replace('{{ key }}', key)
}
output += this.getSectionInnerHTML(
parsedState.sections[section.id],
selector
);
});
} else {
output += this.getSectionInnerHTML(parsedState.sections[section.id]);
}
document.getElementById(section.id).innerHTML = output;
});
if (this.header) {
this.header.reveal();
}
this.open();
}
getSectionsToRender() {
return [
{
id: 'cart-notification-product',
selector: `[id="cart-notification-product-{{ key }}"]`,
},
{
id: 'cart-notification-button',
},
{
id: 'cart-icon-bubble',
},
];
}
Because there can now be multiple keys, the product page CSS also needs a small fix. For example:
/* Gift Wrapping */
.gift-wrap-product label {
display: inline-block;
padding-left: 0.5rem;
margin-bottom: 1rem;
}
.gift-wrap-product textarea,
.gift-wrap-product textarea:focus {
display: block;
width: 100%;
height: 10rem;
padding: 0.6rem;
margin-bottom: 1rem;
font-family: var(--font-body-family);
outline: none;
border: 1px solid #333;
-webkit-box-shadow: none;
box-shadow: none;
}
/* Multiple Notifications */
#cart-notification-product {
flex-wrap: wrap;
}
#cart-notification-product > .cart-notification-product__image {
width: 33%;
margin-right: 0;
margin-top: 0.5rem;
border: 0;
}
#cart-notification-product > :nth-child(even) {
width: 66%;
margin-top: 0.5rem;
}
This will hopefully give people a starting option if they ever want to implement something similar in the future without paying £5 a month for a 3rd party plugin.
[I would urge the Shopify core developers to create a proper implementation of a gift-wrapping system because it is notably missing, and the service is already expensive enough without forcing a micro-transactions model on its users.]
Discover how to increase the efficiency of commerce operations with Shopify Academy's l...
By Jacqui Mar 26, 2025Shopify and our financial partners regularly review and update verification requiremen...
By Jacqui Mar 14, 2025Unlock the potential of marketing on your business growth with Shopify Academy's late...
By Shopify Mar 12, 2025