Let your customers subscribe to an out of stock product with Spree Commerce

Let your customers subscribe to an out of stock product with Spree Commerce

Sometimes it's good to have your customers be notified when an item they wish to purchase comes in stock. In reality they'd go elsewhere to buy, right? If it's available elsewhere, cheaper, they'd pretty much go somewhere else to purchase. But what if what you sell is unique, of better quality or your customer loves your support or you in general? We're going to implement an out of stock email notification system; I call this "notifiees". The beauty of this is that we can name or variables/definitions just the way we like 😂

Before diving right in, let's see how Spree updates the product quantity. The "amount of items in stock" is what we want to target although most businesses do not use "track inventory". We'll come to that in future explanation.

If your business relies on inventory/stock, then this tutorial is for you.

In the StockItemsController, we want to "watch" three actions:

  • Update
  • Create
  • Destroy

Update

When we update a stock item, we should email all customers, providing the quantity is more then 0 🙄

Create

Again, send an email to all customers when stock movement is added.

Destroy

We've decided that we're no longer tracking inventory. This enables the add to cart button so why not email all customers.

# see https://github.com/spree/spree/blob/master/backend/app/controllers/spree/admin/stock_items_controller.rb
module Spree
    module Admin
        module StockItemsControllerDecorator
            def self.prepended(base)
                base.before_action :process_notifiees_on_stock_item, only: :update

                # We have not taken into account should stock_movement.save fails.
                # see https://github.com/spree/spree/blob/master/backend/app/controllers/spree/admin/stock_items_controller.rb#L13
                base.before_action :process_notifiees_on_stock_movement, only: :create

                base.before_action :notify_notifiees, only: :destroy
            end



            private
                # We've made the executive decision by not keeping stocks.
                # Alert all customers that the product is available to purchase.
                def notify_notifiees
                    variant_id = stock_item.variant.id

                    email_all_notifiees(variant_id)
                end

                def process_notifiees_on_stock_movement
                    quantity = params[:stock_movement][:quantity].to_i
                    variant_id = params[:variant_id]

                    if quantity > 0
                        email_all_notifiees(variant_id)
                    end

                end

                def email_all_notifiees(variant_id)
                    product_id = Spree::Variant.find(variant_id).product.id

                    notifiees = lookup_notifiees_by(product_id)

                    send_notification_email(notifiees)

                    # We said we'd delete their email address
                    notifiees.destroy_all
                end

                def process_notifiees_on_stock_item
                    # Backorderable: boolean
                    # stock_item.backorderable

                    # Number of items in stock: integer
                    # stock_item.count_on_hand

                    if stock_item.count_on_hand > 0
                        variant_id = stock_item.variant.id
                        email_all_notifiees(variant_id)
                    end
                end

                def lookup_notifiees_by(product_id)
                    ProductNotification.where(product_id: product_id)
                end

                def send_notification_email(notifiees)
                    if notifiees.present?
                        emails_to_send = notifiees.pluck(:email)
                        # send the email
                    end
                end
        end
    end
end
::Spree::Admin::StockItemsController.prepend Spree::Admin::StockItemsControllerDecorator if ::Spree::Admin::StockItemsController.included_modules.exclude?(Spree::Admin::StockItemsControllerDecorator)

This logic does not take into account where the customer would subscribe to the variant but instead the product itself. Ideally you'd want to change this code to suite your business logic. My online store won't be using different types of variants but as I go along, it will.

In the code you'll notice I have ProductNotification. ProductNotification model is where I save the "notifiees". I save their email address, product id (you may want to save the variant instead, I may change this up) and, optionally, a user id.

Migration

class CreateProductNotifications < ActiveRecord::Migration[6.1]
  def change
    create_table :product_notifications do |t|
      t.references  :user, null: true
      t.references  :product
      t.string      :email

      t.timestamps
    end
  end
end

Model

class ProductNotification < ApplicationRecord
    validates   :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } 
    belongs_to  :user, class_name: 'Spree::User', optional: true
    belongs_to  :product, class_name: 'Spree::Product'
end

The user is optional because I want guests to also subscribe and if a user is logged in, it will use their user id.

Spree Product Model

module Spree
    module ProductDecorator
        def self.prepended(base)
            base.has_many :product_notifications, class_name: 'ProductNotification', foreign_key: 'product_id', dependent: :destroy
        end
    end
end

::Spree::Product.prepend Spree::ProductDecorator if ::Spree::Product.included_modules.exclude?(Spree::ProductDecorator)

Controller

class Spree::Products::NotifyController < ApplicationController
    include ActionView::Helpers::TextHelper

    layout false

    def notify_me
        email = strip_tags(notify_params[:email])

        @notif = ProductNotification.find_or_create_by(email: email) do |perm|
            perm.product_id = notify_params[:product_id]
            perm.user_id = notify_params[:user_id]
        end

        if @notif.save
            @notif_saved = true
        else
            @notif_saved = false
        end
    end

    private
        def notify_params
            params.require(:product_notification).permit(
                :email,
                :product_id
            ).tap do |p|
                # Overkill to have this here, I know.
                p[:user_id] = spree_current_user ? spree_current_user.id : nil
            end
        end
end

Routes

I save all routes under Spree::Core::Engine.add_routes do block:

[..]

Spree::Core::Engine.add_routes do
  [..]
  post '/products/notify', to: 'products/notify#notify_me', as: 'product_notify'
  [..]
end

Frontend

For the frontend, I modify the _cart_form.html and my notify form is shown when I cannot supply:

[..]
<% if !@product.can_supply? %>
  <%= render 'notify_me_when_available' %>
<% end %>

And inside _notify_me_when_available.html.erb:

<form
    data-controller="product-notify"
    data-action="ajax:success->product-notify#result"
    class="product-notify-me mt-4" data-remote="true" action="<%= spree.product_notify_path %>" method="post">
    <input
        type="hidden"
        name="product_notification[product_id]"
        value="<%= @product.id %>"
    />
    <div class="form-group mb-2">
        <label>Notify me when in stock</label>
        <input 
            name="product_notification[email]"
            data-product-notify-target="email"
            class="spree-flat-input" 
            type="text" placeholder="Enter your email address" 
        />    
    </div>
    <span class="product-description">Your email address will only be used for this notification, after which it gets deleted.</span>
    <div>
        <button class="btn btn-primary w-100 text-uppercase font-weight-bold mt-2">Notify me</button>
    </div>
    <div data-product-notify-target="display">
    </div>
</form>

I haven't change things up to use rails form elements as yet but you should. I use Stimulus here, you really don't need to. I'm only making an ajax request to the above controller, passing in the product_id and email then update the UI if any errors.

Inside views/spree/products/notify/notify_me.html.erb:

<%# This is server-rendered %>

<% if @notif_saved %>
    <div class="alert alert-success">
        Great! We'll send you a one-time email when item becomes available.
    </div>
<% else %>
    <div class="alert alert-danger">
        Oops! You may have provided an invalid email address.
    </div>
<% end %>

Stimulus Controller

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "display", "email" ]

  result(event) {
    const data = event.detail[0].body.innerHTML
    if (data.length > 0) {
      this.emailTarget.value = ""
      return this.displayTarget.innerHTML = data
    }
  }
}

Using Stimulus for this is overkill as you could achieve what you want with a simple, plain JavaScript. If you want to use Stimulus, be sure to import the js file in views/spree/shared/_head.html.erb

At this stage, when you save an email address, you should see this:

Show the notifees count

To see the total amount of potential customers watching your product, in views/spree/admin/products/index.html.erb, within the loop, inside the table just add:

<td class="notify"><%= product.product_notifications.count %></td>

Accidentally sending out email

You may want to put a confirmation action on "backordable" as whenever that's ticked/unticked, emails will be sent out 😁

Big Issue

There's a flaw to this implementation. Should you untick/tick "Track Inventory", these actions will never get triggered because that action is located at Spree::Admin::VariantsIncludingMasterController#update which points to here.

Conclusion

I'm still scratching the surface on what Spree Commerce is capable of. I don't feel this implementation is perfect but it's a starting point for me. I will find a better way in doing this but I don't want to go to the Rails event path to watch for database updates. If anyone has a better solution, please let me know. For now, I'll use this method, change the code to have customers subscribe to variant id instead of the product id.

EDIT

Write once, use everywhere. We need or logic in VariantsIncludingMasterController so let's move our code from StockItemsControllerDecorator into a helper method:

module NotifyCustomersHelper
    # We've made the executive decision by not keeping stocks.
    # Alert all customers that the product is available to purchase.
    def notify_notifiees
        variant_id = stock_item.variant.id

        email_all_notifiees(variant_id)
    end

    def process_notifiees_on_stock_movement
        quantity = params[:stock_movement][:quantity].to_i
        variant_id = params[:variant_id]

        if quantity > 0
            email_all_notifiees(variant_id)
        end

    end

    def email_all_notifiees(variant_id)
        product_id = Spree::Variant.find(variant_id).product.id

        notifiees = lookup_notifiees_by(product_id)

        send_notification_email(notifiees)

        # We said we'd delete their email address
        notifiees.destroy_all
    end

    def process_notifiees_on_stock_item
        # Backorderable: boolean
        # stock_item.backorderable

        # Number of items in stock: integer
        # stock_item.count_on_hand

        if stock_item.count_on_hand > 0
            variant_id = stock_item.variant.id
            email_all_notifiees(variant_id)
        end
    end

    def lookup_notifiees_by(product_id)
        ProductNotification.where(product_id: product_id)
    end

    def send_notification_email(notifiees)
        if notifiees.present?
            emails_to_send = notifiees.pluck(:email)
            # send the email
        end
    end
end

Now, our StockItemsControllerDecorator becomes:

# see https://github.com/spree/spree/blob/master/backend/app/controllers/spree/admin/stock_items_controller.rb
module Spree
    module Admin
        module StockItemsControllerDecorator
            include NotifyCustomersHelper

            def self.prepended(base)
                base.before_action :process_notifiees_on_stock_item, only: :update

                # We have not taken into account should stock_movement.save fails.
                # see https://github.com/spree/spree/blob/master/backend/app/controllers/spree/admin/stock_items_controller.rb#L13
                base.before_action :process_notifiees_on_stock_movement, only: :create

                base.before_action :notify_notifiees, only: :destroy
            end
        end
    end
end
::Spree::Admin::StockItemsController.prepend Spree::Admin::StockItemsControllerDecorator if ::Spree::Admin::StockItemsController.included_modules.exclude?(Spree::Admin::StockItemsControllerDecorator)

Next, create VariantsIncludingMasterControllerDecorator inside spree/admin/variants_including_master_controller_decorator.rb:

# See: https://github.com/spree/spree/blob/master/backend/app/controllers/spree/admin/variants_including_master_controller.rb
module Spree
    module Admin
        module VariantsIncludingMasterControllerDecorator
            include NotifyCustomersHelper

            def self.prepended(base)
                base.before_action :send_notification_email_on_inventory_change, only: :update
            end

            def send_notification_email_on_inventory_change
                variant_id = params[:id].to_i
                track_inventory = params[:variant][:track_inventory]

                # If we're no longer tracking, send email
                # track_inventory comes in the form of string "true" or "false"
                if track_inventory == 'false'
                    email_all_notifiees(variant_id)
                end
            end
        end
    end
end
::Spree::Admin::VariantsIncludingMasterController.prepend Spree::Admin::VariantsIncludingMasterControllerDecorator if ::Spree::Admin::VariantsIncludingMasterController.included_modules.exclude?(Spree::Admin::VariantsIncludingMasterControllerDecorator)

Now when we no longer track the inventory, all customers get an email notification. Be careful with this approach. You may accidentally unticked/ticked these boxes which triggers an email to send to all "watchable" customers. You may want to create a dedicated button for this action.

Homework

You will need to pass the product that the customer was watching to the send_notification_email function. In the email, your customer would click a link that takes them directly to the product page.