DEV Community

Daveyon Mayne 😻
Daveyon Mayne 😻

Posted on

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

Alt Text

This is a refactored version of my original post here that uses the spree_frontend. To be able to follow along, please take 2-3 minutes to have a read. v1, as I would call it, uses an outdated version of Spree Commerce. v2 (this post) now uses Spree v4.5.x which uses the spree_backend gem.

I've updated StockItemsControllerDecorator

# https://github.com/spree/spree_backend/blob/main/app/controllers/spree/admin/stock_items_controller.rb
module Spree
  module Admin
    module StockItemsControllerDecorator
      # renamed from NotifyCustomersHelper
      include NotifyCustomers

      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_backend/blob/main/app/controllers/spree/admin/stock_items_controller.rb#L13
        base.before_action :process_notifiees_on_stock_movement, only: :create

        # We don't need to track when deleted as the "track Inventory" can be checked when deleted.
        # For this, look at Spree::Admin::VariantsIncludingMasterController#update
        # 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)
Enter fullscreen mode Exit fullscreen mode

VariantsIncludingMasterControllerDecorator was also refactored as we will only send an email when no longer tracking inventory:

module Spree
  module Admin
    module VariantsIncludingMasterControllerDecorator
      include NotifyCustomers

      def self.prepended(base)
        # Defined in NotifyCustomers. Continue reading...
        base.before_action :notify_notifiees, only: :update
      end
    end
  end
end
::Spree::Admin::VariantsIncludingMasterController.prepend Spree::Admin::VariantsIncludingMasterControllerDecorator if ::Spree::Admin::VariantsIncludingMasterController.included_modules.exclude?(Spree::Admin::VariantsIncludingMasterControllerDecorator)
Enter fullscreen mode Exit fullscreen mode

I've also refactored NotifyCustomers and instead look for the variant instead of the product:

module NotifyCustomers
  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 = params[:id]
      not_tracking = !ActiveRecord::Type::Boolean.new.cast(
        params[:variant][:track_inventory]
      )

      not_tracking && email_all_notifiees(variant_id)
    end

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

      unless quantity.zero?
        email_all_notifiees(variant_id)
      end

    end

    def email_all_notifiees(variant_id)
      notifiees = lookup_notifiees_by(variant_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

      unless stock_item.count_on_hand.zero?
        variant_id = stock_item.variant.id
        email_all_notifiees(variant_id)
      end
    end

    def lookup_notifiees_by(variant_id)
        Spree::VariantNotification.where(variant_id: variant_id)
    end

    def send_notification_email(notifiees)
      if notifiees.present?
        emails_to_send = notifiees.pluck(:email)
        puts "---- SENDING EMAIL TO #{emails_to_send.length} email addresses (plural for now)"

        # Now send the email
        # You'll need to implement a mailer for this
      end
    end
end
Enter fullscreen mode Exit fullscreen mode

Migration file was also updated (I'm doing this in a new project):

class CreateSpreeVariantNotifications < ActiveRecord::Migration[7.0]
  def change
    create_table :spree_variant_notifications do |t|
      t.references  :user
      t.references  :variant, null: false
      t.string      :email, null: false

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Updated models:

module Spree
  class VariantNotification < ApplicationRecord
    validates   :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }

    # Optional as a user doesnt have to be signed in
    belongs_to  :user, class_name: 'Spree::User', optional: true

    belongs_to  :variant, class_name: 'Spree::Variant'
  end
end

module Spree
  module VariantDecorator
    def self.prepended(base)
      base.has_many :notifications, class_name: 'Spree::VariantNotification', foreign_key: 'variant_id', dependent: :destroy
    end
  end
end

::Spree::Variant.prepend Spree::VariantDecorator if ::Spree::Variant.included_modules.exclude?(Spree::VariantDecorator)
Enter fullscreen mode Exit fullscreen mode

Also the place where the email gets processed into our database, the controller:

module Spree
  # Though belongs to a variant, we'll keep this for the product
  module Products
    class NotifyController < Spree::StoreController
      before_action :set_variant
      include ActionView::Helpers::TextHelper

      # Does not uses a layout
      layout false

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

        @notif = @variant.notifications.find_or_create_by(email: email) do |perm|
                    # user_id can be null
                    perm.user_id = spree_current_user&.id || notify_params[:user_id]
                  end

          respond_to do |format|

            if @notif.save
              format.turbo_stream
            else
              format.html { redirect_to spree.product_path(@variant.product), alert: "Something went wrong", status: 422 }
            end
          end
      end


      private
        def notify_params
          params.fetch(:product_notification, {}).permit(:email)
        end

        def set_variant
          @variant = Spree::Variant.find_by(id: params[:variant_id])
          unless @variant.present?
            redirect_to root_path
          end
        end
    end
  end
end

# Route updated to:
post '/products/:variant_id/notify', to: 'products/notify#notify_me', as: 'variant_notify'

Enter fullscreen mode Exit fullscreen mode

_notify_me_when_available.html, inside _cart_form but placed outside the order form was also updated:

<%= form_with(
    scope: :product_notification, 
    url: spree.variant_notify_path(variant),
    data: {
      controller: "reset-form",
      action: "turbo:submit-end->reset-form#reset"
    },
    id: dom_id(variant, :notify)) do |form| %>
  <%= form.label :email, "Notify me when in stock" %>
  <%= form.email_field :email, class: 'spree-flat-input', placeholder: "Enter your email address", required: true %>
  <%= form.submit "Notify me", class: "btn btn-primary w-100 text-uppercase font-weight-bold mt-2" %>
  <p class="mt-2 text-sm">After this notification, your email address will be deleted and not used again.</p>
<% end %>
Enter fullscreen mode Exit fullscreen mode

I'm using turbo in this new project of mine. When form gets submitted, I clear the input field and when successful, I update the DOM with a partial (spree/shared/_variant_notify_success):

<div class="p-3 text-center bg-green-600 text-white sm:text-base text-sm">
  <p>Great! We'll send you a one-time email when item becomes available.</p>
</div>
Enter fullscreen mode Exit fullscreen mode

Using..

<%= turbo_stream.replace dom_id(@variant, :notify) do %>
  <%= render "spree/shared/variant_notify_success" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

...outside the order form:

<%# Outside order form %>
<% unless is_product_available_in_currency && @product.can_supply? %>
  <%= render 'spree/shared/notify_me_when_available', variant: default_variant %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Additional work is needed to show/hide the notification form should a variant is out of stock. My example will only display the notification form when product cannot supply.

Spree frontend has an event lister for .product-variants-variant-values-radio as seen here. You could put your logic there to show/hide the notification form. Here's the logic:

Inside your click event for OPTION_VALUE_SELECTOR (in your own js file or use Stimulus):

With the selected variant_id, parse the data for data-variants on the cart from. Inside the array of data-variants, check that the variant is purchasable, using variants.find(o.id == variant_id).purchasable, for example. Based on true or false, show/hide the notification form. I got purchasable from here.

That should be all for now. At the time of this post, I have not implement show/hide when an option is selected. Live demo here.

Top comments (0)