In this tutorial, we're showing you how to easily add auto-generated in-cart product upsells to your Shopify store without any apps or knowing how to code. Customers can add these recommended products to their cart without leaving the page, making it a highly effective way to increase your AOV.
Compatible Themes: This code should work on all free Shopify themes (Dawn, Refresh, Craft, Studio, Publisher, Crave, Origin, Taste, Colorblock, Sense, Ride, Spotlight).
Add cart recommendations settings to settings_schema.json
{
"name": "Cart Drawer Recommendations Upsell",
"settings": [
{
"type": "checkbox",
"id": "enable_cart_upsell",
"label": "Enable Cart Drawer Recommendations",
"default": false
},
{
"type": "header",
"content": "Formatting Settings"
},
{
"type": "color",
"id": "cart_drawer_recommendations_background_color",
"label": "Background Color",
"default": "#f2f2f2"
},
{
"type": "text",
"id": "cart_drawer_recommendations_heading",
"label": "Heading Text",
"default": "You may also like"
},
{
"type": "color",
"id": "cart_drawer_recommendations_heading_color",
"label": "Heading Color",
"default": "#333333"
},
{
"type": "range",
"id": "cart_drawer_recommendations_heading_font_size",
"label": "Heading Font Size",
"min": 16,
"max": 28,
"step": 1,
"default": 20
},
{
"type": "select",
"id": "cart_drawer_recommendations_heading_font_weight",
"label": "Heading Font Weight",
"default": "bold",
"options": [
{
"value": "normal",
"label": "Normal"
},
{
"value": "bold",
"label": "Bold"
},
{
"value": "bolder",
"label": "Bolder"
}
]
},
{
"type": "range",
"id": "cart_drawer_recommendation_media_width",
"label": "Media Width",
"min": 30,
"max": 60,
"step": 5,
"default": 45
},
{
"type": "checkbox",
"id": "cart_drawer_recommendation_hide_variant_titles",
"label": "Hide Variant Titles",
"default": true
},
{
"type": "select",
"id": "cart_drawer_recommendation_picker_type",
"options": [
{
"value": "dropdown",
"label": "t:sections.main-product.blocks.variant_picker.settings.picker_type.options__1.label"
},
{
"value": "button",
"label": "t:sections.main-product.blocks.variant_picker.settings.picker_type.options__2.label"
}
],
"default": "dropdown",
"label": "t:sections.main-product.blocks.variant_picker.settings.picker_type.label"
},
{
"id": "cart_drawer_recommendation_swatch_shape",
"label": "t:sections.main-product.blocks.variant_picker.settings.swatch_shape.label",
"type": "select",
"info": "t:sections.main-product.blocks.variant_picker.settings.swatch_shape.info",
"options": [
{
"value": "circle",
"label": "t:sections.main-product.blocks.variant_picker.settings.swatch_shape.options__1.label"
},
{
"value": "square",
"label": "t:sections.main-product.blocks.variant_picker.settings.swatch_shape.options__2.label"
},
{
"value": "none",
"label": "t:sections.main-product.blocks.variant_picker.settings.swatch_shape.options__3.label"
}
],
"default": "none"
},
{
"type": "color",
"id": "cart_drawer_recommendation_btn_color",
"label": "Add To Cart Button Color",
"default": "#000000"
},
{
"type": "color",
"id": "cart_drawer_recommendation_btn_text_color",
"label": "Add To Cart Button Text Color",
"default": "#ffffff"
},
{
"type": "header",
"content": "Upsell Product Selection Settings"
},
{
"type": "select",
"id": "cart_drawer_upsell_type",
"label": "Cart Upsell Type",
"options": [
{
"value": "recommendations",
"label": "Auto Recommendations"
},
{
"value": "complementary",
"label": "Complementary"
}
],
"default": "recommendations",
"info": "Auto Recommendations are automatically generated based on cart contents. Complementary is based on selected complementary products in the Shopify Search & Discovery app."
},
{
"type": "range",
"id": "cart_drawer_recommendations_products_to_show",
"label": "Upsell Products to Show",
"min": 1,
"max": 5,
"step": 1,
"default": 3
},
{
"type": "header",
"content": "Recommendations Settings"
},
{
"type": "range",
"id": "cart_drawer_recommendations_products_to_recommend",
"label": "Products to Recommend per Cart Item",
"min": 1,
"max": 10,
"step": 1,
"default": 4,
"info": "For Auto Recommendations calculations only. Adjust this number to increase the pool of potential products to recommend in the final ranking. Must be equal or greater than the setting Upsell Products To Show."
},
{
"type": "range",
"id": "recommendation_weight_quantity",
"label": "Recommendation Weight Factor: Quantity",
"min": 0,
"max": 1,
"step": 0.1,
"default": 0.3
},
{
"type": "range",
"id": "recommendation_weight_frequency",
"label": "Recommendation Weight Factor: Frequency",
"min": 0,
"max": 1,
"step": 0.1,
"default": 0.4
},
{
"type": "range",
"id": "recommendation_weight_position",
"label": "Recommendation Weight Factor: Position",
"min": 0,
"max": 1,
"step": 0.1,
"default": 0.3
}
]
}
Add cart recommendations snippet to cart-drawer.liquid
{% if settings.enable_cart_upsell and cart.items.size > 0 %}
{% render 'cart-drawer-recommendations' %}
{% endif %}
Create new liquid snippet cart-drawer-recommendations.liquid
{% comment %}
Generates and renders cart drawer recommendations
{% endcomment %}
<div class="cart-drawer__recommendations">
{% assign products_to_recommend = settings.cart_drawer_recommendations_products_to_recommend %}
{% assign products_to_show = settings.cart_drawer_recommendations_products_to_show %}
{% assign section_id = "cart-drawer" %}
{% capture cart_items_json %}
[
{% for item in cart.items %}
{
"product_id": {{ item.product_id | json }},
"quantity": {{ item.quantity | json }}
}{% unless forloop.last %},{% endunless %}
{% endfor %}
]
{% endcapture %}
<div class="cart-drawer__recommendations-heading">{{ settings.cart_drawer_recommendations_heading }}</div>
<comprehensive-cart-recommendations
class="complementary-products"
data-url="{{ routes.product_recommendations_url }}?limit={{ products_to_recommend }}{% if settings.cart_drawer_upsell_type == 'complementary' %}&intent=complementary{% endif %}"
data-section-id="{{ section_id }}"
data-cart-items="{{ cart_items_json | strip_newlines | strip | escape }}"
data-max-position="{{ products_to_recommend }}"
data-products-to-show="{{ products_to_show }}"
data-weight-quantity="{{ settings.recommendation_weight_quantity }}"
data-weight-frequency="{{ settings.recommendation_weight_frequency }}"
data-weight-position="{{ settings.recommendation_weight_position }}"
>
{% if recommendations.performed and recommendations.products_count > 0 %}
<ul class="grid product-grid">
{% for recommendation in recommendations.products %}
<li class="grid__item">
{% render 'cart-drawer-upsell-product', product: recommendation, section_id: section_id %}
</li>
{% endfor %}
</ul>
{% endif %}
</comprehensive-cart-recommendations>
</div>
<style>
.cart-drawer__recommendations {
margin-top: 20px;
border-top: 1px solid var(--color-border);
background-color: {{ settings.cart_drawer_recommendations_background_color }};
padding: 10px;
display: none;
}
.cart-drawer__recommendations.has-recommendations {
display: block;
}
.cart-drawer__recommendations .complementary-products .grid__item {
width: 100%;
max-width: 100%;
margin-bottom: 10px;
}
.cart-drawer__recommendations-heading {
font-size: {{ settings.cart_drawer_recommendations_heading_font_size }}px;
font-weight: {{ settings.cart_drawer_recommendations_heading_font_weight }};
color: {{ settings.cart_drawer_recommendations_heading_color }};
margin-bottom: 20px;
margin-top: 0;
text-align: center;
}
.product-recommendations.no-markers {
list-style-type: none;
padding-left: 0;
margin: 0;
}
.product-recommendations.no-markers li {
list-style-type: none;
}
.product-recommendations.no-markers li::marker {
display: none;
}
.cart-upsell-item {
margin-bottom: 20px;
}
.cart-upsell-item__inner {
display: flex;
align-items: flex-start;
gap: 15px;
padding: 15px;
border-color: rgba(var(--color-foreground), 0.75);
background: var(--gradient-background);
}
.cart-upsell-item__media-wrapper {
flex: 0 0 {{ settings.cart_drawer_recommendation_media_width }}%;
}
.cart-upsell-item__media img {
width: 100%;
height: auto;
display: block;
border-radius: 4px;
}
.cart-upsell-item__content {
flex: 1;
min-width: 0;
}
.cart-upsell-item__title {
font-size: 16px;
font-weight: 600;
margin: 0 0 10px;
line-height: 1.2;
}
.cart-upsell-item__link {
color: inherit;
text-decoration: none;
}
.cart-upsell-item__link:hover {
text-decoration: underline;
}
.cart-upsell-item__price {
font-size: 14px;
margin-bottom: 10px;
}
.cart-upsell-item__variants {
margin-bottom: 15px;
}
.cart-upsell-item__variants select {
width: 100%;
padding: 4px 8px;
border-color: rgba(var(--color-foreground), 0.75);
font-size: 14px;
}
.cart-upsell-item__variants .product-form__input--dropdown {
margin-bottom: 0.7rem;
}
{% if settings.cart_drawer_recommendation_hide_variant_titles %}
.cart-upsell-item__variants .product-form__input .form__label {
display: none;
}
{% endif %}
.cart-upsell-item__variants .product-form__input .select__select {
height: 3rem;
}
.cart-upsell-item__variants .product-form__input--pill input[type=radio]+label {
margin: .5rem .5rem .3rem 0;
padding: 0.5rem 1rem;
font-size: 1.25rem;
letter-spacing: 0.06rem;
}
.cart-upsell-item__variants .product-form__input--swatch .swatch-input__input+.swatch-input__label {
--swatch-input--size: 2.3rem;
margin: .5rem 1.0rem .2rem 0;
}
.cart-upsell-item__button .product-form__submit {
width: 100%;
font-size: 14px;
padding: 10px 15px;
border-radius: 4px;
border: none;
background-color: {{ settings.cart_drawer_recommendation_btn_color }};
color: {{ settings.cart_drawer_recommendation_btn_text_color }};
cursor: pointer;
transition: background-color 0.3s ease;
}
.cart-upsell-item__button .button:after {
box-shadow: 0 0 0 calc(var(--buttons-border-width) + var(--border-offset))
rgba(var(--color-button-text), var(--border-opacity)),
0 0 0 var(--buttons-border-width) {{ settings.cart_drawer_recommendation_btn_color }};
}
.cart-upsell-item__button .button:not([disabled]):hover::after {
box-shadow: 0 0 0 calc(var(--buttons-border-width) + var(--border-offset))
rgba(var(--color-button-text), var(--border-opacity)),
0 0 0 calc(var(--buttons-border-width) + 1px) {{ settings.cart_drawer_recommendation_btn_color }};
}
</style>
{{ 'section-main-product.css' | asset_url | stylesheet_tag }}
{{ 'component-price.css' | asset_url | stylesheet_tag }}
{{ 'component-product-variant-picker.css' | asset_url | stylesheet_tag }}
Create new liquid snippet cart-drawer-upsell-product.liquid
{% comment %}
Renders a simplified featured product for use in cart upsells
Accepts:
- product: {Object} Product object
- section_id: {String} The ID of the section containing this product
{% endcomment %}
{%- assign product_form_id = 'product-form-' | append: section_id | append: '-' | append: product.id -%}
<product-info-cart-upsell
id="ProductInfo-{{ section_id }}-{{ product.id }}"
data-section="{{ section_id }}"
data-url="{{ product.url }}"
data-product-id="{{ product.id }}"
data-product-handle="{{ product.handle }}"
data-is-upsell
class="cart-upsell-item"
data-product-info
>
<div class="cart-upsell-item__inner cart-upsell-product-container">
<div class="cart-upsell-item__media-wrapper">
{% assign media = product.selected_or_first_available_variant.featured_media | default: product.featured_media %}
{% if media %}
<div class="cart-upsell-item__media">
<img
srcset="{%- if media.width >= 165 -%}{{ media | image_url: width: 165 }} 165w,{%- endif -%}
{%- if media.width >= 360 -%}{{ media | image_url: width: 360 }} 360w,{%- endif -%}
{{ media | image_url }} {{ media.width }}w"
src="{{ media | image_url: width: 165 }}"
sizes="(min-width: 990px) 165px, 360px"
alt="{{ media.alt | escape }}"
loading="lazy"
width="{{ media.width }}"
height="{{ media.height }}"
id="ProductImage-{{ section_id }}-{{ product.id }}"
data-product-image
>
</div>
{% else %}
{{ 'product-1' | placeholder_svg_tag: 'placeholder-svg' }}
{% endif %}
</div>
<div class="cart-upsell-item__content">
<div class="cart-upsell-item__info-wrapper">
<h3 class="cart-upsell-item__title">
<a href="{{ product.url }}" class="cart-upsell-item__link">
{{ product.title | escape }}
</a>
</h3>
<div id="price-{{ section_id }}-{{ product.id }}" role="status" class="cart-upsell-item__price" data-price-wrapper>
{%- render 'price',
product: product,
use_variant: true,
show_badges: false,
disable_cart_upsell_currency_code: true
-%}
</div>
{% unless product.has_only_default_variant %}
<div class="cart-upsell-item__variants" data-variant-picker>
{% render 'product-variant-picker-cart-upsell', product: product, product_form_id: product_form_id, section_id: section_id, is_upsell: true %}
</div>
{% endunless %}
<div class="cart-upsell-item__button" data-product-form>
<product-form
class="product-form"
data-section-id="{{ section.id }}"
>
<div class="product-form__error-message-wrapper" role="alert" hidden>
<span class="svg-wrapper">
{{- 'icon-error.svg' | inline_asset_content -}}
</span>
<span class="product-form__error-message"></span>
</div>
{%- form 'product',
product,
id: product_form_id,
class: 'form',
novalidate: 'novalidate',
data-type: 'add-to-cart-form'
-%}
<input
type="hidden"
name="id"
value="{{ product.selected_or_first_available_variant.id }}"
{% if product.selected_or_first_available_variant.available == false
or quantity_rule_soldout
or product.selected_or_first_available_variant == null
%}
disabled
{% endif %}
class="product-variant-id"
>
<div class="product-form__buttons">
{%- liquid
assign check_against_inventory = true
if product.selected_or_first_available_variant.inventory_management != 'shopify' or product.selected_or_first_available_variant.inventory_policy == 'continue'
assign check_against_inventory = false
endif
if product.selected_or_first_available_variant.quantity_rule.min > product.selected_or_first_available_variant.inventory_quantity and check_against_inventory
assign quantity_rule_soldout = true
endif
-%}
<button
id="ProductSubmitButton-{{ section_id }}-{{ product.id }}"
type="submit"
name="add"
class="product-form__submit button button--full-width button--primary"
{% if product.selected_or_first_available_variant.available == false
or quantity_rule_soldout
or product.selected_or_first_available_variant == null
%}
disabled
{% endif %}
>
<span>
{%- if product.selected_or_first_available_variant == null -%}
{{ 'products.product.unavailable' | t }}
{%- elsif product.selected_or_first_available_variant.available == false or quantity_rule_soldout -%}
{{ 'products.product.sold_out' | t }}
{%- else -%}
{{ 'products.product.add_to_cart' | t }}
{%- endif -%}
</span>
{%- render 'loading-spinner' -%}
</button>
</div>
{%- endform -%}
</product-form>
</div>
</div>
</div>
</div>
</product-info-cart-upsell>
Create new liquid section cart-drawer-upsell-product.liquid
{% comment %}
Section: cart-upsell-product
This section renders the product-info-cart-upsell component for the given product.
{% endcomment %}
{% render 'cart-drawer-upsell-product', product: product, section_id: 'cart-drawer-upsell-product' %}
Create new snippet product-variant-picker-cart-upsell.liquid
{% comment %}
Renders product variant-picker
Accepts:
- product: {Object} product object.
- block: {Object} passing the block information.
- product_form_id: {String} Id of the product form to which the variant picker is associated.
Usage:
{% render 'product-variant-picker', product: product, block: block, product_form_id: product_form_id %}
{% endcomment %}
{%- unless product.has_only_default_variant -%}
<variant-selects-cart-upsell
id="variant-selects-{{ section.id }}-{{ product.id }}"
data-section="{{ section.id }}"
data-product-id="{{ product.id }}"
data-is-upsell="true"
>
{%- for option in product.options_with_values -%}
{%- liquid
assign swatch_count = option.values | map: 'swatch' | compact | size
assign picker_type = settings.cart_drawer_recommendation_picker_type
assign swatch_shape = settings.cart_drawer_recommendation_swatch_shape
if swatch_count > 0 and swatch_shape != 'none'
if picker_type == 'dropdown'
assign picker_type = 'swatch_dropdown'
else
assign picker_type = 'swatch'
endif
endif
-%}
{%- if picker_type == 'swatch' -%}
<fieldset class="js product-form__input product-form__input--swatch">
<legend class="form__label">
{{ option.name }}:
<span data-selected-value>
{{- option.selected_value -}}
</span>
</legend>
{% render 'product-variant-options',
product: product,
option: option,
block: block,
picker_type: picker_type,
is_upsell: is_upsell
%}
</fieldset>
{%- elsif picker_type == 'button' -%}
<fieldset class="js product-form__input product-form__input--pill">
<legend class="form__label">{{ option.name }}</legend>
{% render 'product-variant-options',
product: product,
option: option,
block: block,
picker_type: picker_type,
is_upsell: is_upsell
%}
</fieldset>
{%- else -%}
<div class="product-form__input product-form__input--dropdown">
<label class="form__label" for="Option-{{ section.id }}-{{ product.id }}-{{ forloop.index0 }}">
{{ option.name }}
</label>
<div class="select">
{%- if picker_type == 'swatch_dropdown' -%}
<span
data-selected-value
class="dropdown-swatch"
>
{% render 'swatch', swatch: option.selected_value.swatch, shape: swatch_shape %}
</span>
{%- endif -%}
<select
id="Option-{{ section.id }}-{{ product.id }}-{{ forloop.index0 }}"
class="select__select"
name="options[{{ option.name | escape }}]"
form="{{ product_form_id }}"
>
{% render 'product-variant-options',
product: product,
option: option,
block: block,
picker_type: picker_type,
is_upsell: is_upsell
%}
</select>
<span class="svg-wrapper">
{{- 'icon-caret.svg' | inline_asset_content -}}
</span>
</div>
</div>
{%- endif -%}
{%- endfor -%}
<script type="application/json" data-selected-variant>
{{ product.selected_or_first_available_variant | json }}
</script>
<script type="application/json" id="ProductJSON-{{ product.id }}">
{{ product | json }}
</script>
</variant-selects-cart-upsell>
{%- endunless -%}
Edit price.liquid
- Replace
settings.currency_code_enabled
withcurrency_code_enabled
- Add variable assignment for
currency_code_enabled
if disable_cart_upsell_currency_code
assign currency_code_enabled = false
else
assign currency_code_enabled = settings.currency_code_enabled
endif
Edit product-variant-options.liquid
- Add variable assignment
assign is_upsell = is_upsell | default: false
if is_upsell
assign product_form_id = product_form_id | append: '-' | append: product.id
endif
if block != null
assign swatch_shape = block.settings.swatch_shape
else
assign swatch_shape = settings.cart_drawer_recommendation_swatch_shape
endif
- Add
{% if is_upsell %}-{{ product.id }}{% endif %}
to input_id and input_name
{{ section.id }}{% if is_upsell %}-{{ product.id }}{% endif %}-{{ option.position }}-{{ forloop.index0 }}
{{ option.name }}-{{ option.position }}{% if is_upsell %}-{{ product.id }}{% endif %}
- Replace
block.settings.swatch_shape
withswatch_shape
Create new asset file cart-drawer-recommendations.js
class ComprehensiveCartRecommendations extends HTMLElement {
constructor() {
super();
this.weightQuantity = this.parseWeight(this.dataset.weightQuantity, 0.3);
this.weightFrequency = this.parseWeight(this.dataset.weightFrequency, 0.4);
this.weightPosition = this.parseWeight(this.dataset.weightPosition, 0.3);
}
parseWeight(value, defaultValue) {
const parsed = parseFloat(value);
return isNaN(parsed) ? defaultValue : parsed;
}
connectedCallback() {
try {
const cartItemsString = this.dataset.cartItems.replace(/"/g, '"');
this.cartItems = JSON.parse(cartItemsString);
this.combinedCartItems = this.combineVariants(this.cartItems);
this.maxPosition = parseInt(this.dataset.maxPosition, 10) || 4;
this.productsToShow = parseInt(this.dataset.productsToShow, 10) || this.maxPosition;
} catch (e) {
this.cartItems = [];
this.combinedCartItems = [];
this.maxPosition = 4;
this.productsToShow = 4;
}
if (this.combinedCartItems.length > 0) {
this.initializeRecommendations();
}
}
combineVariants(cartItems) {
const combinedItems = {};
cartItems.forEach(item => {
const productId = item.product_id;
if (combinedItems[productId]) {
combinedItems[productId].quantity += item.quantity;
} else {
combinedItems[productId] = { ...item };
}
});
return Object.values(combinedItems);
}
initializeRecommendations() {
this.loadRecommendations();
}
loadRecommendations() {
const productIds = this.combinedCartItems.map(item => item.product_id);
const recommendationPromises = productIds.map(id => this.fetchRecommendationsForProduct(id));
Promise.all(recommendationPromises)
.then(allRecommendations => {
const processedRecommendations = this.processRecommendations(allRecommendations, productIds);
this.renderRecommendations(processedRecommendations);
})
.catch(error => {
console.error('Error loading recommendations:', error);
});
}
async fetchRecommendationsForProduct(productId) {
const url = `${this.dataset.url}&product_id=${productId}§ion_id=${this.dataset.sectionId}`;
try {
const response = await fetch(url);
const text = await response.text();
const html = document.createElement('div');
html.innerHTML = text;
const productCards = html.querySelectorAll('.cart-drawer__recommendations .grid__item');
return Array.from(productCards).map(card => this.extractProductData(card));
} catch (error) {
console.error(`Error fetching recommendations for product ${productId}:`, error);
return [];
}
}
extractProductData(card) {
const productInfoElement = card.querySelector('product-info-cart-upsell');
if (productInfoElement) {
const productId = productInfoElement.dataset.productId;
return {
id: productId,
element: card.cloneNode(true)
};
}
return null;
}
processRecommendations(allRecommendations, sourceProductIds) {
const totalCartQuantity = this.combinedCartItems.reduce((sum, item) => sum + item.quantity, 0);
const totalCartItems = this.combinedCartItems.length;
const maxPosition = this.maxPosition;
const processedRecommendations = [];
allRecommendations.forEach((recommendations, index) => {
const sourceProductId = sourceProductIds[index];
const sourceCartItem = this.combinedCartItems.find(item => item.product_id === sourceProductId);
const sourceQuantity = sourceCartItem ? sourceCartItem.quantity : 1;
recommendations.forEach((rec, position) => {
if (!rec) return;
const existingRec = processedRecommendations.find(r => r.id === rec.id);
const invertedPosition = maxPosition + 1 - (position + 1);
if (existingRec) {
existingRec.count++;
existingRec.recommendedBy.push({
productId: sourceProductId,
position: position + 1,
quantity: sourceQuantity,
invertedPosition: invertedPosition
});
} else {
processedRecommendations.push({
...rec,
count: 1,
recommendedBy: [{
productId: sourceProductId,
position: position + 1,
quantity: sourceQuantity,
invertedPosition: invertedPosition
}],
originalPosition: null
});
}
});
});
processedRecommendations.forEach((rec, index) => {
rec.originalPosition = index + 1;
});
processedRecommendations.forEach((rec) => {
rec.totalQuantity = rec.recommendedBy.reduce((sum, item) => sum + item.quantity, 0);
rec.timesRecommended = rec.count;
rec.avgInvertedPosition = rec.recommendedBy.reduce((sum, item) => sum + item.invertedPosition, 0) / rec.recommendedBy.length;
const { score, Q_p_norm, N_p_norm, P_p_norm } = this.calculateScore(rec, totalCartQuantity, totalCartItems, maxPosition);
rec.score = score;
rec.Q_p_norm = Q_p_norm;
rec.N_p_norm = N_p_norm;
rec.P_p_norm = P_p_norm;
});
processedRecommendations.sort((a, b) => b.score - a.score);
return processedRecommendations;
}
calculateScore(recommendation, totalCartQuantity, totalCartItems, maxPosition) {
const Q_p_norm = recommendation.totalQuantity / totalCartQuantity;
const N_p_norm = recommendation.timesRecommended / totalCartItems;
const P_p_norm = recommendation.avgInvertedPosition / maxPosition;
const w_Q = this.weightQuantity;
const w_N = this.weightFrequency;
const w_P = this.weightPosition;
const score = (w_Q * Q_p_norm) + (w_N * N_p_norm) + (w_P * P_p_norm);
return {
score,
Q_p_norm,
N_p_norm,
P_p_norm
};
}
renderRecommendations(recommendations) {
const productsToShow = parseInt(this.dataset.productsToShow, 10) || recommendations.length;
const limitedRecommendations = recommendations.slice(0, productsToShow);
const container = document.createElement('div');
container.className = 'product-recommendations no-markers';
limitedRecommendations.forEach((item) => {
if (item && item.element) {
container.appendChild(item.element);
}
});
this.innerHTML = '';
this.appendChild(container);
const recommendationsContainer = this.closest('.cart-drawer__recommendations');
if (recommendationsContainer) {
if (limitedRecommendations.length > 0) {
recommendationsContainer.classList.add('has-recommendations');
} else {
recommendationsContainer.classList.remove('has-recommendations');
}
}
if (limitedRecommendations.length > 0) {
this.classList.add('product-recommendations--loaded');
} else {
this.classList.remove('product-recommendations--loaded');
}
setTimeout(() => {
this.dispatchEvent(new CustomEvent('cart-recommendations-rendered', { bubbles: true }));
}, 0);
}
}
customElements.define('comprehensive-cart-recommendations', ComprehensiveCartRecommendations);
class ProductInfoCartUpsell {
static instance = null;
constructor() {
if (ProductInfoCartUpsell.instance) {
return ProductInfoCartUpsell.instance;
}
ProductInfoCartUpsell.instance = this;
this.handleRecommendationsRendered = this.handleRecommendationsRendered.bind(this);
this.eventHandlers = new Map();
this.initialize();
}
initialize() {
this.container = document.querySelector('.cart-drawer__recommendations');
if (!this.container) {
return;
}
this.initProductElements();
this.addEventListeners();
}
removeEventListeners() {
if (this.productElements) {
this.productElements.forEach((productElement) => {
this.removeEventListenersFromElement(productElement);
});
}
document.removeEventListener('cart-recommendations-rendered', this.handleRecommendationsRendered);
}
removeEventListenersFromElement(element) {
const variantSelects = element.querySelector('variant-selects-cart-upsell');
if (variantSelects) {
const handler = this.eventHandlers.get(variantSelects);
if (handler) {
variantSelects.removeEventListener('cart-upsell-variant-change', handler);
this.eventHandlers.delete(variantSelects);
}
}
const productForm = element.querySelector('product-form');
if (productForm) {
const handler = this.eventHandlers.get(productForm);
if (handler) {
productForm.removeEventListener('submit', handler);
this.eventHandlers.delete(productForm);
}
}
}
initProductElements() {
this.removeEventListeners();
this.productElements = this.container.querySelectorAll('.cart-upsell-item');
this.productElements.forEach((productElement) => {
this.initializeProductHandlers(productElement);
});
}
addEventListeners() {
document.addEventListener('cart-recommendations-rendered', this.handleRecommendationsRendered);
}
handleRecommendationsRendered() {
this.initialize();
}
initializeProductHandlers(productElement) {
const variantSelects = productElement.querySelector('variant-selects-cart-upsell');
if (variantSelects) {
const variantChangeHandler = this.handleVariantChange.bind(this, productElement);
this.eventHandlers.set(variantSelects, variantChangeHandler);
variantSelects.addEventListener('cart-upsell-variant-change', variantChangeHandler);
}
const productForm = productElement.querySelector('product-form');
if (productForm) {
const addToCartHandler = this.handleAddToCart.bind(this, productElement);
this.eventHandlers.set(productForm, addToCartHandler);
productForm.addEventListener('submit', addToCartHandler);
}
}
handleVariantChange(productElement, event) {
const variantSelects = event.target.closest('variant-selects-cart-upsell');
if (variantSelects) {
const selectedOptions = event.detail.selectedOptionValues;
const variantData = this.getVariantIdByOptions(productElement, selectedOptions);
if (variantData) {
const productHandle = productElement.dataset.productHandle;
this.renderProductInfo(productElement, productHandle, variantData.id);
}
}
}
getSelectedOptions(variantSelects) {
const optionElements = variantSelects.querySelectorAll('[name^="options["]');
const selectedOptions = [];
optionElements.forEach((element) => {
if (element.tagName === 'SELECT') {
selectedOptions.push(element.value);
} else if (element.tagName === 'INPUT' && element.type === 'radio' && element.checked) {
selectedOptions.push(element.value);
}
});
return selectedOptions;
}
getVariantIdByOptions(productElement, selectedOptions) {
const selector = '#ProductJSON-' + productElement.dataset.productId;
const variantJson = productElement.querySelector(selector);
if (!variantJson) {
return null;
}
const productData = JSON.parse(variantJson.textContent);
const variant = productData.variants.find((v) =>
selectedOptions.every((option, index) => v.options[index] === option)
);
return variant ? { id: variant.id, variant: variant } : null;
}
renderProductInfo(productElement, productHandle, variantId) {
const url = this.buildProductUrl(productHandle, variantId);
fetch(url)
.then((response) => response.text())
.then((responseText) => {
const html = new DOMParser().parseFromString(responseText, 'text/html');
const newProductInfo = html.querySelector('product-info-cart-upsell');
if (newProductInfo) {
this.updateUpsellProductInfo(productElement, newProductInfo);
}
})
.catch((error) => {
console.error('Error updating product info:', error);
});
}
buildProductUrl(productHandle, variantId) {
const params = new URLSearchParams({
variant: variantId,
section_id: 'cart-drawer-upsell-product'
});
return `/products/${productHandle}?${params.toString()}`;
}
updateUpsellProductInfo(oldProductElement, newProductElement) {
const productId = oldProductElement.dataset.productId;
const sectionId = oldProductElement.dataset.section;
this.removeEventListenersFromElement(oldProductElement);
oldProductElement.innerHTML = newProductElement.innerHTML;
oldProductElement.dataset.productId = productId;
oldProductElement.dataset.section = sectionId;
this.initializeProductHandlers(oldProductElement);
}
handleAddToCart(productElement, event) {
document.addEventListener('cart:update', (event) => {
// Handle any additional actions after cart update
}, { once: true });
}
}
new ProductInfoCartUpsell();
class VariantSelectsCartUpsell extends VariantSelects {
constructor() {
super();
}
connectedCallback() {
this.addEventListener('change', (event) => {
const target = this.getInputForEventTarget(event.target);
this.updateSelectionMetadata(event);
this.dispatchEvent(
new CustomEvent('cart-upsell-variant-change', {
bubbles: false,
detail: {
event,
target,
selectedOptionValues: this.selectedOptionValues,
},
})
);
});
}
get selectedOptionValues() {
const selectedOptions = [];
this.querySelectorAll('select').forEach((select) => {
selectedOptions.push(select.value);
});
this.querySelectorAll('input[type="radio"]:checked').forEach((input) => {
selectedOptions.push(input.value);
});
return selectedOptions;
}
}
customElements.define('variant-selects-cart-upsell', VariantSelectsCartUpsell);
Edit CartItems class in cart.js
Edit method constructor
if (!event.target.closest('.cart-upsell-product-container') && !event.target.closest('product-info-cart-upsell')) {
this.onChange(event);
}
Edit method resetQuantityInput
resetQuantityInput(id) {
const input = this.querySelector(`#Quantity-${id}`);
if (input && !input.closest('.cart-upsell-product-container') && !input.closest('product-info-cart-upsell')) {
const value = input.getAttribute('value');
if (value !== null) {
input.value = value;
}
this.isEnterPressed = false;
}
}
Edit method setValidity
setValidity(event, index, message) {
if (event.target.setCustomValidity) {
event.target.setCustomValidity(message);
event.target.reportValidity();
}
this.resetQuantityInput(index);
if (event.target.select && typeof event.target.select === 'function') {
event.target.select();
}
}
Edit method validateQuantity
if (event.target.closest('.cart-upsell-product-container') || event.target.closest('product-info-cart-upsell')) {
return;
}
Edit theme.liquid
{% if settings.enable_cart_upsell %}
<script src="{{ 'product-form.js' | asset_url }}" defer="defer"></script>
<script src="{{ 'cart-drawer-recommendations.js' | asset_url }}" defer="defer"></script>
{% endif %}