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)

SurveyJS custom survey software

JavaScript Form Builder UI Component

Generate dynamic JSON-driven forms directly in your JavaScript app (Angular, React, Vue.js, jQuery) with a fully customizable drag-and-drop form builder. Easily integrate with any backend system and retain full ownership over your data, with no user or form submission limits.

Learn more

๐Ÿ‘‹ Kindness is contagious

Please leave a โค๏ธ or a friendly comment on this post if you found it helpful!

Okay