----- Updated 👉 Refactored version ------
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.
Top comments (0)