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:
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.]