Personalized checkout and custom promotions with Shopify Scripts
In our store we have some custom discount logic that does the following:
Here is our scripts:
# ================================ Customizable Settings ================================
# ================================================================
# Buy X of Product Y for $Z
#
# Buy a certain number of matching items for a specific price.
# For example:
#
# "Buy 2 t-shirts for $20"
#
# - 'product_selector_match_type' determines whether we look for
# products that do or don't match the entered selectors. Can
# be:
# - ':include' to check if the product does match
# - ':exclude' to make sure the product doesn't match
# - 'product_selector_type' determines how eligible products
# will be identified. Can be either:
# - ':tag' to find products by tag
# - ':type' to find products by type
# - ':vendor' to find products by vendor
# - ':product_id' to find products by ID
# - ':variant_id' to find products by variant ID
# - ':subscription' to find subscription products
# - ':all' for all products
# - 'product_selectors' is a list of identifiers (from above)
# for qualifying products. Product/Variant ID lists should
# only contain numbers (ie. no quotes). If ':all' is used,
# this can also be 'nil'.
# - 'quantity_to_buy' is the number of products needed to
# qualify
# - 'final_price` is the amount to charge for all products that
# are part of the offer
# - 'discount_message' is the message to show when a discount
# is applied
# ================================================================
MULTIBUY_DISCOUNTS = [
{
product_selector_match_type: :include,
product_selector_type: :type,
product_selectors: ["T-Shirts"],
product_price: 40,
quantity_to_buy: 3,
final_price: 105,
discount_message: 'Buy 3 for $105',
},
{
product_selector_match_type: :include,
product_selector_type: :type,
product_selectors: ["Pants"],
product_price: 89,
quantity_to_buy: 2,
final_price: 160,
discount_message: 'Buy 2 for $160',
}
]
#### Multibuy Items storage ####
# Store multibuy product ids to run the DisableDiscountCodesCampaign
multibuy_items = []
# ================================ Script Code (do not edit) ================================
# ================================================================
# ProductSelector
#
# Finds matching products by the entered criteria.
# ================================================================
class ProductSelector
def initialize(match_type, selector_type, selectors)
@match_type = match_type
@comparator = match_type == :include ? 'any?' : 'none?'
@Selector_type = selector_type
@selectors = selectors
end
def match?(line_item)
if self.respond_to?(@selector_type)
self.send(@selector_type, line_item)
else
raise RuntimeError.new('Invalid product selector type')
end
end
def tag(line_item)
product_tags = line_item.variant.product.tags.map { |tag| tag.downcase.strip }
@selectors = @selectors.map { |selector| selector.downcase.strip }
(@selectors & product_tags).send(@comparator)
end
def type(line_item)
@selectors = @selectors.map { |selector| selector.downcase.strip }
(@match_type == :include) == @selectors.include?(line_item.variant.product.product_type.downcase.strip)
end
def vendor(line_item)
@selectors = @selectors.map { |selector| selector.downcase.strip }
(@match_type == :include) == @selectors.include?(line_item.variant.product.vendor.downcase.strip)
end
def product_id(line_item)
(@match_type == :include) == @selectors.include?(line_item.variant.product.id)
end
def variant_id(line_item)
(@match_type == :include) == @selectors.include?(line_item.variant.id)
end
def subscription(line_item)
!line_item.selling_plan_id.nil?
end
def all(line_item)
true
end
end
# ================================================================
# DollarDiscountApplicator
#
# Applies the entered discount to the supplied line item.
# ================================================================
class DollarDiscountApplicator
def initialize(discount_message)
@discount_message = discount_message
end
def apply(cart, line_item, discount_amount)
new_line_price = line_item.line_price - discount_amount
if (line_item.line_price > new_line_price)
line_item.change_line_price(new_line_price, message: @discount_message)
end
end
end
# ================================================================
# BuyXOfYForZCampaign
#
# Buy a certain number of matching items for a specific price.
# ================================================================
class BuyXOfYForZCampaign
def initialize(campaigns)
@campaigns = campaigns
end
def run(cart, multibuy_items)
@campaigns.each do |campaign|
product_selector = ProductSelector.new(
campaign[:product_selector_match_type],
campaign[:product_selector_type],
campaign[:product_selectors],
)
product_price = campaign[:product_price]
eligible_items = cart.line_items.select { |line_item| line_item.variant.price == Money.new(cents: 100) * product_price && product_selector.match?(line_item) }
next if eligible_items.nil?
final_price = campaign[:final_price]
eligible_item_count = eligible_items.map(&:quantity).reduce(0, :+)
quantity_to_buy = campaign[:quantity_to_buy]
number_of_offers = (eligible_item_count / quantity_to_buy).floor
next unless number_of_offers > 0
number_of_discountable_items = number_of_offers * quantity_to_buy
total_offer_price = Money.new(cents: 100) * (number_of_offers * final_price)
discount_applicator = DollarDiscountApplicator.new(campaign[:discount_message])
self.loop_items(cart, eligible_items, number_of_discountable_items, number_of_offers, total_offer_price, discount_applicator, final_price, quantity_to_buy, product_price, multibuy_items)
end
end
def loop_items(cart, line_items, num_to_discount, number_of_offers, total_price, discount_applicator, final_price, quantity_to_buy, product_price, multibuy_items)
current_price = Money.zero
avg_price = total_price * (1 / num_to_discount)
line_items = line_items.sort_by { |line_item| line_item.variant.price }
line_items.each do |line_item|
break if num_to_discount <= 0
if line_item.quantity > num_to_discount
split_line_item = line_item.split(take: num_to_discount)
discount_amount = split_line_item.line_price - (total_price - current_price)
multibuy_items.push(line_item.variant.product.id)
discount_applicator.apply(cart, split_line_item, discount_amount)
position = cart.line_items.find_index(line_item)
cart.line_items.insert(position + 1, split_line_item)
break
elsif line_item.quantity == num_to_discount
discount_amount = line_item.line_price - (total_price - current_price)
multibuy_items.push(line_item.variant.product.id)
discount_applicator.apply(cart, line_item, discount_amount)
break
else
if line_item.variant.price <= avg_price
current_price += line_item.line_price
else
discount_amount = (line_item.variant.price - avg_price) * line_item.quantity
current_price += (line_item.line_price - discount_amount)
multibuy_items.push(line_item.variant.product.id)
discount_applicator.apply(cart, line_item, discount_amount)
end
num_to_discount -= line_item.quantity
end
end
end
end
# ================================================================
# DisableDiscountCodesCampaign
#
# Disable discount code if Multibuy products are in the cart
# ================================================================
class DisableDiscountCodesCampaign
def run(cart, multibuy_items)
return if cart.discount_code.nil? or multibuy_items.length > 0
# Runs through a loop of items in your cart
cart.line_items.each do |line_item|
product = line_item.variant.product
# next if product.gift_card? or product.product_type == "Gift Card"
# skip multibuy line item
next if multibuy_items.include?(product.id)
# store the discount code message before it's removed later
discount_message = cart.discount_code.code
# 1. check discount code type applied to checkout
case cart.discount_code
when CartDiscount::Percentage
# 2. collects all the info from the discount
discount_amount = cart.discount_code.percentage.floor().to_s.to_i
value = 1 - (discount_amount * 0.01)
new_line_price = line_item.line_price * value
# store again the discount message as a string (it was breaking the logic below for some reason)
d_message = "#{discount_message}"
# 3. update the price of the line item with the discount code discounted price and message
line_item.change_line_price(new_line_price, message: d_message)
# 4. then remove the discount code from the basket
cart.discount_code.reject({ message: 'Discount cannot be applied on multibuy offers' })
end
end
end
end
CAMPAIGNS = [
BuyXOfYForZCampaign.new(MULTIBUY_DISCOUNTS),
DisableDiscountCodesCampaign.new()
]
CAMPAIGNS.each do |campaign|
campaign.run(Input.cart, multibuy_items)
end
Output.cart = Input.cart
The code above works, but I feel like there must be a better way to achieve this. I've also noticed that debugging within the Shopify Scripts editor is not efficient and results in this editor are not the same as the actual store. Perhaps there's a more efficient way to write these scripts that I could benefit from?
Much appreciated.
Update:
The code above worked well within the Scripts Editor debugging tool but was less efficient in a production environment. It just shows that you can't rely on Shopify's Script Editor, is doesn't match the live environment that customers experience.
The way "discount.reject" works is not optimal. It seems Shopify clears the discount object inside the cart data once "discount.reject" is used. It would be much better if discount code data had a state machine. So instead of removing any discount data from the cart object, it would simply update the state (status) of the discount "ACTIVE" or "INACTIVE". That way, even if the discount is not applicable to the cart we can still use the discount values of the discount to update the prices of each line item if we desire to do so.
Hey Community! As the holiday season unfolds, we want to extend heartfelt thanks to a...
By JasonH Dec 6, 2024Dropshipping, a high-growth, $226 billion-dollar industry, remains a highly dynamic bus...
By JasonH Nov 27, 2024Hey Community! It’s time to share some appreciation and celebrate what we have accomplis...
By JasonH Nov 14, 2024