Episodes

Resources

Download Source Code

Summary

# Terminal
rails g model cart token:uniq
rails g model cart_items cart:belongs_to product:belongs_to quantity:integer

# db/migrate/20220129024530_create_cart_items.rb
class CreateCartItems < ActiveRecord::Migration[7.0]
  def change
    create_table :cart_items do |t|
      t.belongs_to :cart, null: false, foreign_key: true
      t.belongs_to :product, null: false, foreign_key: true
      t.integer :quantity, default: 0

      t.timestamps
    end
  end
end

# config/routes.rb
Rails.application.routes.draw do
  namespace :carts do
    resource :add, only: :create
    resource :reduce, only: :create
    resource :remove, only: :destroy
  end
  resource :checkout, only: :show
  resources :products
  post :search, to: "searches#show"
  root to: 'welcome#index'
end

# application_controller.rb
class ApplicationController < ActionController::Base

  def current_cart
    cart = Cart.find_or_create_by(token: cookies[:cart_token])
    cookies[:cart_token] ||= cart.token
    cart
  end
  helper_method :current_cart

end

# carts/adds_controller.rb
module Carts
  class AddsController < ApplicationController
    def create
      if product_found?
        cart_item = current_cart.cart_items.find_or_initialize_by(product_id: params[:product])
        cart_item.quantity += 1
        cart_item.save
      end
    end

    private

    def product_found?
      Product.exists?(params[:product])
    end
  end
end

# carts/reduces_controller.rb
module Carts
  class ReducesController < ApplicationController
    def create
      if product_found?
        cart_item = current_cart.cart_items.find_or_initialize_by(product_id: params[:product])
        cart_item.quantity = [cart_item.quantity - 1, 1].max
        cart_item.save
      end
    end

    private

    def product_found?
      Product.exists?(params[:product])
    end
  end
end

# carts/removes_controller.rb
module Carts
  class RemovesController < ApplicationController
    def destroy
      if product_found?
        cart_item = current_cart.cart_items.find_by(product_id: params[:product])
        cart_item.destroy
      end
    end

    private

    def product_found?
      Product.exists?(params[:product])
    end
  end
end

# checkouts_controller.rb
class CheckoutsController < ApplicationController
  def show
  end
end

# searches_controller.rb
class SearchesController < ApplicationController
  def show
    @products = ProductSearch.call(params)
    respond_to do |format|
      format.turbo_stream {
        render turbo_stream: turbo_stream.replace(
          "products_display",
          partial: "products/display",
          locals: { products: @products }
        )
      }
    end
  end
end

# welcome_controller.rb
class WelcomeController < ApplicationController
  def index
    @products = ProductSearch.call(params)
  end
end

# models/cart.rb
class Cart < ApplicationRecord
  has_secure_token
  has_many :cart_items, dependent: :destroy

  def quantity
    cart_items.sum(&:quantity)
  end
end

# models/cart_item.rb
class CartItem < ApplicationRecord
  include ActionView::RecordIdentifier

  belongs_to :cart
  belongs_to :product

  after_create_commit do
    broadcast_replace_to cart,
                         target: "cart_count",
                         partial: "carts/item_count",
                         locals: { count: cart.quantity }
    broadcast_replace_to cart,
                         target: "total_price",
                         partial: "carts/total_price",
                         locals: { current_cart: cart }
  end

  after_update_commit do
    broadcast_replace_to cart,
                         target: "cart_count",
                         partial: "carts/item_count",
                         locals: { count: cart.quantity }

    broadcast_replace_to cart,
                         target: dom_id(self, "quantity"),
                         partial: "carts/item_quantity",
                         locals: { cart_item: self }
    broadcast_replace_to cart,
                         target: "total_price",
                         partial: "carts/total_price",
                         locals: { current_cart: cart }
  end

  after_destroy_commit do
    broadcast_remove_to cart
    broadcast_replace_to cart,
                         target: "cart_count",
                         partial: "carts/item_count",
                         locals: { count: cart.quantity }
    broadcast_replace_to cart,
                         target: "total_price",
                         partial: "carts/total_price",
                         locals: { current_cart: cart }
  end

  def total_price
    quantity.to_i * product.price.to_f
  end
end

# models/product_search.rb
class ProductSearch
  def self.call(params)
    new(params).call
  end

  def initialize(params)
    @params = params
  end

  def call
    products
      .where(search_condition)
      .where(price_condition)
      .where(category_condition)
      .order(sort_condition)
  end

  private

  def products
    @products ||= Product.includes(image_attachment: :blob).all
  end

  def search_condition
    return unless @params.dig(:search, :query)

    ["name LIKE ?", "%#{@params.dig(:search, :query)}%"]
  end

  def price_condition
    return unless @params.dig(:search, :price)
    conditions = [].tap do |array|
      array << (..1000) if @params.dig(:search, :price).include?("lt1000")
      array << (1000..2500) if @params.dig(:search, :price).include?("bewteen1000and2500")
      array << (2500..) if @params.dig(:search, :price).include?("gt2500")
    end
    { price: conditions }
  end

  def category_condition
    return unless @params.dig(:search, :category_id)

    { category_id: @params.dig(:search, :category_id) }
  end

  def sort_condition
    return unless @params.dig(:search, :sort)

    case @params.dig(:search, :sort)
    when "price_lth"
      { price: :asc }
    when "price_htl"
      { price: :desc }
    end
  end
end

# views/welcome/index.html.erb
<%= turbo_stream_from current_cart %>
<%= render "carts/checkout_button" %>

<div class="row">
  <div class="col-12 col-lg-3">
    <%= turbo_frame_tag "filtering" do %>
      <div class="h3">Filtering</div>
      <%= form_for :search, url: search_path do |f| %>
        <div class="input-group mb-3">
          <%= f.text_field :query, class: "form-control" %>
          <%= f.submit "Search", class: "btn btn-outline-secondary" %>
        </div>

        <%= f.select :sort, [
            ["Price Low to High", :price_lth],
            ["Price High to Low", :price_htl]
          ], { include_blank: true },
          class: "form-select mb-3" %>


        <div class="h4">Price</div>
        <ul class="list-group mb-3">

          <li class="list-group-item">
            <div class="form-check">
              <%= f.check_box :price, { multiple: true, class: "form-check-input" } ,
                "lt1000", nil %>
              <%= f.label :price, "< $1000", class: "form-check-label" %>
            </div>
          </li>

          <li class="list-group-item">
            <div class="form-check">
              <%= f.check_box :price, { multiple: true, class: "form-check-input" } ,
                "bewteen1000and2500", nil %>
              <%= f.label :price, "$1000 - $2500", class: "form-check-label" %>
            </div>
          </li>

          <li class="list-group-item">
            <div class="form-check">
              <%= f.check_box :price, { multiple: true, class: "form-check-input" } ,
                "gt2500", nil %>
              <%= f.label :price, "> $2500", class: "form-check-label" %>
            </div>
          </li>

        </ul>

        <div class="h4">Category</div>
        <ul class="list-group mb-3">
          <% Category.all.each do |category| %>
            <li class="list-group-item">
              <div class="form-check">
                <%= f.check_box :category_id, { multiple: true, class: "form-check-input" } ,
                  category.id, nil %>
                <%= f.label :category_id, category.name, class: "form-check-label" %>
              </div>
            </li>
           <% end %>
        </ul>

      <% end %>
    <% end %>
  </div>
  <div class='col-12 col-lg-9'>
    <%= render "products/display", products: @products %>
  </div>
</div>

# views/products/_display.html.erb
<%= turbo_frame_tag "products_display" do %>
  <div class="row">
    <% products.each_slice(3) do |product_group| %>
      <% product_group.each do |product| %>
        <div class="col-12 col-md-6 col-lg-4">
          <div class="card mb-5">
            <%= image_tag product.image, class: "card-img-top" %>
            <div class="card-body">
              <h5 class="card-title">
                <%= product.name %><br>
                <%= number_to_currency(product.price) %>
              <h5>
              <p class="card-text">Some quick example text to build on the card title and make up the bulk of the card's content.</p>
              <%= button_to "Add to Cart", carts_add_path, params: { product: product }, class: "btn btn-primary" %>
            </div>
          </div>
        </div>
      <% end %>
    <% end %>
  </div>
<% end %>

# views/checkouts/show.html.erb
<%= turbo_stream_from current_cart %>
<div class="py-16 px-6 px-md-14 bg-white">
  <div class="d-flex mb-12 align-items-center">
    <h3 class="mb-0">Order summary</h3>
    <span class="flex-shrink-0 d-inline-flex ms-4 align-items-center justify-content-center rounded-circle bg-primary text-white" style="width: 32px; height: 32px;">
      <%= render "carts/item_count", count: current_cart.quantity %>
    </span>
  </div>
  <div class="mb-12 pb-16 border-bottom">
    <% current_cart.cart_items.each do |cart_item| %>
      <%= render "carts/item_line", cart_item: cart_item %>
    <% end %>
  </div>
  <div class="mb-12">
    <div class="mb-10">
      <div class="py-3 px-10 rounded-pill">
        <div class="d-flex justify-content-between">
          <span class="lead fw-bold" data-config-id="row4">Total</span>
          <%= render "carts/total_price" %>
        </div>
      </div>
    </div>
  </div>

  <a class="btn btn-primary w-100 text-uppercase" href="#" data-config-id="primary-action">Confirm Order</a>
</div>

# views/carts/_checkout_button.html.erb
<div class="mb-3">
  <%= link_to checkout_path, class: "btn btn-outline-dark" do %>
    <div class="badge bg-primary">
      <%= render "carts/item_count", count: current_cart.quantity %>
    </div>
    Checkout
  <% end %>
</div>

# views/carts/_item_count.html.erb
<%= content_tag :div, count, id: "cart_count" %>

# view/carts/_item_line.html.erb
<%= content_tag :div, class: "row mb-8 align-items-center", id: dom_id(cart_item) do %>
  <div class="align-self-stretch col-12 col-lg-3 mb-4 mb-md-0">
    <%= image_tag cart_item.product.image, class: "img-fluid" %>
  </div>
  <div class="col-12 col-md-9">
    <div class="d-flex justify-content-between">
      <div class="pe-2">
        <h3 class="mb-2 lead fw-bold"><%= cart_item.product.name %></h3>
      </div>
      <div>
        <span class="lead text-info fw-bold"><%= number_to_currency(cart_item.product.price) %></span>
        <%= button_to "remove",
          carts_remove_path,
          params: { product: cart_item.product },
          method: :delete %>
      </div>
    </div>
    <div class="d-flex align-items-center justify-content-between">
      <div></div>
      <div class="d-inline-flex align-items-center px-4 fw-bold text-secondary border rounded-2">
        <%= button_to "-", carts_reduce_path, params: { product: cart_item.product }, class: "btn px-0 py-2" %>
        <div class="form-control m-0 px-2 py-4 text-center text-md-end border-0">
          <%= render "carts/item_quantity", cart_item: cart_item %>
        </div>
        <%= button_to "+", carts_add_path, params: { product: cart_item.product }, class: "btn px-0 py-2" %>
      </div>
    </div>
  </div>
<% end %>

# view/carts/_item_quantity.html.erb
<%= content_tag :span, cart_item.quantity, id: dom_id(cart_item, "quantity") %>

# view/carts/_total_price.html.erb
<span class="fw-bold" id="total_price">
  <%= number_to_currency(current_cart.cart_items.sum(&:total_price)) %>
</span>

Side Note: You can clean up the cart_item model a bit by removing the duplicate broadcasts and call a private method which will broadcast the partials.

# models/cart_item.rb
  after_create_commit do
    update_item_count
    update_total_price
  end

  after_update_commit do
    update_item_count
    update_total_price
    broadcast_replace_to cart,
                         target: dom_id(self, "quantity"),
                         partial: "carts/item_quantity",
                         locals: { cart_item: self }
  end

  after_destroy_commit do
    broadcast_remove_to cart
    update_item_count
    update_total_price
  end

  private

  def update_total_price
    broadcast_replace_to cart,
                         target: "total_price",
                         partial: "carts/total_price",
                         locals: { current_cart: cart }
  end

  def update_item_count
    broadcast_replace_to cart,
                         target: "cart_count",
                         partial: "carts/item_count",
                         locals: { count: cart.quantity }
  end