Chapter 11

カートに関する機能を実装しよう

FarStep
FarStep
2022.12.10に更新

はじめに

本 Chapter では、カート機能を実装していきます。
商品の詳細画面で、顧客が商品をカートに入れられるようにしていきましょう。

モデル・テーブルを作成しよう

最初に CartItem モデル・cart_items テーブルの作成を行います。
コンテナが立ち上がっていることが確認できましたら、ecommerce_web コンテナに入ります。

$ docker-compose run --rm web bash
Creating ecommerce_web_run ... done
ruby@884097f5536a:/app$

無事コンテナに入ることができましたら、下記コマンドを実行してください。

$ rails g model CartItem quantity:integer customer:references product:references

reference 型 を使うと外部キー制約のついたカラムを作成することができます。

上記コマンドを実行するとマイグレーションファイルが生成されるはずです。
db/migrate/xxxxxxxxxxxxxx_create_cart_items.rb を開いて下記コードを記述してください。
(x には数字が入ります)

class CreateCartItems < ActiveRecord::Migration[7.0]
  def change
    create_table :cart_items do |t|
      t.integer :quantity, null: false, default: 1
      t.references :customer, null: false, foreign_key: true
      t.references :product, null: false, foreign_key: true

      t.timestamps
    end
  end
end

quantity カラムには、null: false 制約と、デフォルト値を 1 に設定しました。
マイグレーションファイルの編集が完了したら、下記コマンドを実行して cart_items テーブルを作成しましょう。

$ rails db:migrate

db/schema.rb に 下記のように cart_items テーブルが追加されていれば OK です。

db/schema.rb
create_table "cart_items", force: :cascade do |t|
  t.integer "quantity", default: 1, null: false
  t.bigint "customer_id", null: false
  t.bigint "product_id", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["customer_id"], name: "index_cart_items_on_customer_id"
  t.index ["product_id"], name: "index_cart_items_on_product_id"
end

続いて、アソシエーションの記述を行います。
app/models/cart_item.rb を開いてください。
既に下記コードが記述されているはず。

app/models/cart_item.rb
class CartItem < ApplicationRecord
  belongs_to :customer
  belongs_to :product
end

なぜ自動的に belongs_to という記述がされているかというと、マイグレーションファイルを作成する際に、reference 型を使ったためです。
customers テーブルと cart_items テーブル、products テーブルと cart_items テーブルがそれぞれ 一対多 で結ばれますので、app/models/cart_item.rb には、belongs_to が記述されています。

ただし、has_many の記述はされていないため、手動で追加しましょう。
app/models/customer.rb を開いて下記コードを追加してください。

app/models/customer.rb
class Customer < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  with_options presence: true do
    validates :name
    validates :status
  end
  enum status: {
    normal: 0,
    withdrawn: 1,
    banned: 2
  }
+ has_many :cart_items, dependent: :destroy
end

同様に app/models/product.rb を開いて下記コードを追加してください。

app/models/product.rb
class Product < ApplicationRecord
  with_options presence: true do
    validates :name
    validates :description
    validates :price
    validates :stock
    validates :image
  end
  has_one_attached :image
  scope :price_high_to_low, -> { order(price: :desc) }
  scope :price_low_to_high, -> { order(price: :asc) }
+ has_many :cart_items, dependent: :destroy
end

これで、下記のようなアソシエーションが実現できました。

コントローラを作成しよう

カート機能を処理するコントローラを作成する前に、商品をカートに追加できるよう、空の CartItem モデルを ProductsController の show アクションに定義してあげます。
app/controllers/customer/products_controller.rb を開いてください。

app/controllers/customer/products_controller.rb
class Customer::ProductsController < ApplicationController
  def index
    @products, @sort = get_products(params)
  end

  def show
    @product = Product.find(params[:id])
+   @cart_item = CartItem.new
  end

  private

  def get_products(params)
    return Product.all, 'default' unless params[:latest] || params[:price_high_to_low] || params[:price_low_to_high]

    return Product.latest, 'latest' if params[:latest]

    return Product.price_high_to_low, 'price_high_to_low' if params[:price_high_to_low]

    return Product.price_low_to_high, 'price_low_to_high' if params[:price_low_to_high]
  end
end

@cart_item を form_with のモデルに渡すことで、カート内商品を新たに作成することができます。

次に、カート機能を処理するコントローラを作成します。
下記コマンドを実行して、必要なコントローラを作成しましょう。

$ rails g controller customer/cart_items index

customer ディレクトリ配下に CartItemsController を作成するために、customer/cart_items としています。

続いて、ルーティングの設定を行います。
config/routes.rb を開いて下記コードを追加してください。

config/routes.rb
Rails.application.routes.draw do
  devise_for :admins, controllers: {
    sessions: 'admin/sessions'
  }
  devise_for :customers, controllers: {
    sessions: 'customer/sessions',
    registrations: 'customer/registrations'
  }
  root to: 'pages#home'
  namespace :admin do
    resources :products, only: %i[index show new create edit update]
  end
  scope module: :customer do
    resources :products, only: %i[index show]
+   resources :cart_items, only: %i[index create destroy] do
+     member do
+       patch 'increase'
+       patch 'decrease'
+     end
+   end
  end

  get '/up/', to: 'up#index', as: :up
  get '/up/databases', to: 'up#databases', as: :up_databases
end

新たに、member というメソッドが登場しました。
この member メソッドを使うと cart_item の id が含まれる URL を扱えるようになります。
実際に、生成されたパスを見てみましょう。

$ rails routes | grep cart_items

下記のようなパスが出力されたでしょうか。

increase_cart_item PATCH  /cart_items/:id/increase(.:format)    customer/cart_items#increase
decrease_cart_item PATCH  /cart_items/:id/decrease(.:format)    customer/cart_items#decrease
        cart_items GET    /cart_items(.:format)                 customer/cart_items#index
                   POST   /cart_items(.:format)                 customer/cart_items#create
        cart_item  DELETE /cart_items/:id(.:format)             customer/cart_items#destroy

member メソッドを使うと、下記のように cart_item の id を含んだレコードを更新するための URL を作成することができます。このパスは、カート内の商品の個数を変更する 際に使います。

increase_cart_item PATCH  /cart_items/:id/increase(.:format)    customer/cart_items#increase
decrease_cart_item PATCH  /cart_items/:id/decrease(.:format)    customer/cart_items#decrease

では次に、コントローラの各アクションの中身を記述していきます。
app/controllers/customer/cart_items_controller.rb を開いて下記コードを記述してください。

app/controllers/customer/cart_items_controller.rb
class Customer::CartItemsController < ApplicationController
  before_action :authenticate_customer!
  before_action :set_cart_item, only: %i[increase decrease destroy]

  def index
    @cart_items = current_customer.cart_items
  end

  def create
    increase_or_create(params[:cart_item][:product_id])
    redirect_to cart_items_path, notice: 'Successfully added product to your cart'
  end

  def increase
    @cart_item.increment!(:quantity, 1)
    redirect_to request.referer, notice: 'Successfully updated your cart'
  end

  def decrease
    decrease_or_destroy(@cart_item)
    redirect_to request.referer, notice: 'Successfully updated your cart'
  end

  def destroy
    @cart_item.destroy
    redirect_to request.referer, notice: 'Successfully deleted one cart item'
  end

  private

  def set_cart_item
    @cart_item = current_customer.cart_items.find(params[:id])
  end

  def increase_or_create(product_id)
    cart_item = current_customer.cart_items.find_by(product_id:)
    if cart_item
      cart_item.increment!(:quantity, 1)
    else
      current_customer.cart_items.build(product_id:).save
    end
  end

  def decrease_or_destroy(cart_item)
    if cart_item.quantity > 1
      cart_item.decrement!(:quantity, 1)
    else
      cart_item.destroy
    end
  end
end

通常の CRUD 処理に比べて少し複雑ですので一つ一つのアクションを一緒に確認していきましょう。

まずは、index アクションですね。
このアクション内では、現在ログインしている顧客のカート内商品を全て取得しています。
current_customer が nil のときには、エラーが吐かれてしまうため、before_action :authenticate_customer! を記述して、確実に current_customer に現在ログインしている顧客の情報が格納されるようにしています。(他のアクションも同様です)
また、current_customer.cart_items という記述ができるのは、app/models/customer.rb にアソシエーションの記述をしたからです。

次に、create アクションです。
このアクション内では、カートへの商品の追加 or 既にカート内に存在する商品の個数の更新 を行っています。
既にカート内に存在する商品をもう一度カートに追加した場合には、商品の個数のみを変更すればよいため、二通りの処理を記述する必要があります。この処理を increase_or_create というメソッドに切り出しました。
increase_or_create の中身は下記の通りです。

def increase_or_create(product_id)
  cart_item = current_customer.cart_items.find_by(product_id:)
  if cart_item
    cart_item.increment!(:quantity, 1)
  else
    current_customer.cart_items.build(product_id:).save
  end
end

一行目で、現在ログインしている顧客のカート内商品の中から、顧客がカートに追加しようとしている商品を探しています。その結果を cart_item という変数に格納していますね。
cart_item に何かしらの値が格納された場合、つまり、nil では無い場合には、既にその商品がカートに追加されているということですので、カート内商品の個数を1増やすだけで OK です。ちなみに、特定のカラムをインクリメントするメソッド(increment メソッド)が ActiveRecord に用意されています。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/persistence.rb#L845

逆に、cart_item が nil の場合は、その商品は初めてカートに追加されるということですので、CartItem を新たに作成する必要があります。そこで、current_customer.cart_items.build(product_id:).save という記述で、カート内商品を新たに作成しています。current_customer.cart_items.build と書くことで、現在ログインしている顧客に紐づける(customer_id に current_customerid をセットする)ことができます。あとは、product_id をセットして、保存すれば新たにカート内商品が作成されるというわけです。
(quantity にはデフォルト値である 1 がセットされています。)

続いて、increase アクションです。
このアクション内では、カート内商品の個数を1増やす という処理を行っています。
ActiveRecord に用意されているカラムの値をインクリメントするメソッド、increment メソッドを使用して、quantity カラムの値を1増やしています。increment メソッドの第一引数にカラム名、第二引数に増加させる値をセットしています。
また、quantity カラムの変更後、元いた画面(カート内商品一覧画面)にリダイレクトさせるため、redirect_to request.referer としています。

次に、decrease アクションです。
このアクション内では、カート内商品の個数を1減らす or カート内商品を削除する という処理を行っています。
なぜ二通りの処理があるかというと、カート内商品の quantity の値は1以上である必要があります。
もしも quantity が1より小さくなった場合、そのカート内商品は存在しないということですので、削除しなければなりません。そのため、quantity の値が1より小さい時には、カート内商品を削除するという処理を decrease_or_destroy メソッドに切り出しました。

decrease_or_destroy メソッドの中身は下記の通りです。

def decrease_or_destroy(cart_item)
  if cart_item.quantity > 1
    cart_item.decrement!(:quantity, 1)
  else
    cart_item.destroy
  end
end

一行目で、quantity が1以上かどうかを判定しています。
もしも1以上だったら、ActiveRecord で用意されている decrement メソッドを使って、カート内商品の個数を1減らす処理を実行しています。
もしも1より小さかったら、カート内商品を削除するという処理を行っています。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/activerecord/lib/active_record/persistence.rb#L866

最後に destroy アクションですが、こちらは通常の削除機能と何ら変わりません。

ビューを作成しよう

それでは、最後に Tailwind CSS を使ってビューを作成していきましょう。
まずは、商品をカートに追加するボタンを設置します。
app/views/customer/products/show.html.erb を開いて、フォームを追加してください。

app/views/customer/products/show.html.erb
<div class="relative mx-auto max-w-screen-xl p-6">
  <div class="grid grid-cols-1 items-start gap-9 md:grid-cols-2">
    <div>
      <%= image_tag @product.image, class: "aspect-square w-full rounded-xl object-cover" %>
    </div>
    <div class="sticky top-0">
      <div class="flex flex-col justify-between">
        <div class="flex justify-between mb-6">
          <div class="max-w-[35ch]">
            <h1 class="text-2xl font-bold">
              <%= @product.name %>
            </h1>
          </div>
          <p class="text-2xl font-bold"><%= number_to_currency(@product.price, unit: "¥", strip_insignificant_zeros: true) %></p>
        </div>
        <div class="mb-3">
          <p>
            <%= @product.description %>
          </p>
        </div>
        <div class="mb-8">
          <% if @product.stock > 0 %>
            <span class="bg-blue-100 text-blue-800 text-xs font-semibold p-2 rounded">In stock (<%= @product.stock %>)</span>
          <% else %>
            <span class="bg-red-100 text-red-800 text-xs font-semibold p-2 rounded">Out of stock</span>
          <% end %>
        </div>
        <% if @product.stock > 0 %>
          <%= form_with model: @cart_item, data: { turbo: false } do |f| %>
            <%= f.hidden_field :product_id, :value => @product.id %>
            <%= f.submit "Add to Cart", class:"w-full cursor-pointer focus:outline-none text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2" %>
          <% end %>
        <% end %>
      </div>
    </div>
  </div>
</div>

上記の記述ができましたら、http://localhost:8000/products/1 にアクセスしてみましょう。
id は作成した product の id を指定してください。

上記のように、カートへ追加するボタンが表示されていれば OK です。
顧客からはボタンしか表示されていませんが、画面に表示されている商品の id を hidden_field によってコントローラに送信することができます。

https://guides.rubyonrails.org/v5.2/action_view_overview.html#hidden-field

次に、カート内商品一覧画面を作成していきます。
app/views/customer/cart_items/index.html.erb を開いてください。

app/views/customer/cart_items/index.html.erb
<div class="mb-8 text-center">
  <span class="text-3xl font-bold">
    Shopping Cart
  </span>
</div>

<% if @cart_items.count == 0 %>
  <div class='mx-auto max-w-3xl'>
    <p class='mb-8 text-xl text-center'>No item</p>
    <div class='flex justify-center'>
      <%= link_to products_path, class: 'group inline-flex items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-purple-600 to-blue-500 p-0.5 font-medium text-gray-900 hover:text-white focus:ring-4 focus:ring-blue-300 group-hover:from-purple-600 group-hover:to-blue-500' do %>
        <span class='rounded-md bg-white px-5 py-2.5 transition-all duration-75 ease-in group-hover:bg-opacity-0'>
          Find Products
        </span>
      <% end %>
    </div>
  </div>
<% else %>
  <div class="xl:flex">
    <div class="px-6 flex justify-center mb-7 xl:w-2/3">
      <div class="overflow-x-auto">
        <table>
          <thead>
            <tr class="bg-gray-100 text-gray-600 uppercase text-sm leading-normal">
              <th class="py-4 px-8">product</th>
              <th class="py-4 px-8">price</th>
              <th class="py-4 px-8">quantity</th>
              <th class="py-4 px-8">total</th>
              <th class="py-4 px-8"></th>
            </tr>
          </thead>
          <tbody class="text-center">
            <% @cart_items.each do |cart_item| %>
              <tr class="focus:outline-none h-16 border border-gray-100 rounded">
                <td class="p-3 whitespace-nowrap">
                  <div class="flex flex-col justify-center mx-6">
                    <span class="font-bold text-xl"><%= cart_item.product.name %></span>
                    <span class="text-sm"><%= cart_item.product.stock %> left in stock</span>
                  </div>
                </td>
                <td class="p-3">
                  <span class="text-lg"><%= number_to_currency(cart_item.product.price, unit: "¥", strip_insignificant_zeros: true) %></span>
                </td>
                <td class="p-3">
                  <div class="flex justify-center">
                    <%= link_to decrease_cart_item_path(cart_item), data: { "turbo-method": :patch }, class: "flex justify-center" do %>
                      <svg class="fill-current text-red-500 w-3" viewBox="0 0 448 512">
                        <path d="M416 208H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h384c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"/>
                      </svg>
                    <% end %>
                    <span class="mx-5 text-lg"><%= cart_item.quantity %></span>
                    <% unless cart_item.quantity >= cart_item.product.stock %>
                      <%= link_to increase_cart_item_path(cart_item), data: { "turbo-method": :patch }, class: "flex justify-center" do %>
                        <svg class="fill-current text-blue-500 w-3" viewBox="0 0 448 512">
                          <path d="M416 208H272V64c0-17.67-14.33-32-32-32h-32c-17.67 0-32 14.33-32 32v144H32c-17.67 0-32 14.33-32 32v32c0 17.67 14.33 32 32 32h144v144c0 17.67 14.33 32 32 32h32c17.67 0 32-14.33 32-32V304h144c17.67 0 32-14.33 32-32v-32c0-17.67-14.33-32-32-32z"/>
                        </svg>
                      <% end %>
                    <% end %>
                  </div>
                </td>
                <td class="p-3">
                  <span class="text-lg"></span>
                </td>
                <td class="p-3">
                  <div class="flex justify-center">
                    <%= link_to cart_item_path(cart_item), data: { "turbo-method": :delete, "turbo_confirm": 'Are you sure?' } do %>
                      <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-trash text-red-500" viewBox="0 0 16 16">
                        <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
                        <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
                      </svg>
                    <% end %>
                  </div>
                </td>
              </tr>
            <% end %>
          </tbody>
        </table>
      </div>
    </div>
    <div class="px-6 xl:w-1/3">
      <h1 class="font-semibold text-2xl border-b pb-6 text-center">
        Cart Summary
      </h1>
      <div class="flex justify-between my-6">
        <span class="font-semibold text-lg uppercase">subtotal</span>
        <span class="font-semibold text-lg"></span>
      </div>
      <div class="flex justify-between my-6">
        <span class="font-semibold text-lg uppercase">postage</span>
        <span class="font-semibold text-lg"></span>
      </div>
      <div class="flex justify-between my-6">
        <span class="font-semibold text-lg uppercase">total</span>
        <span class="font-semibold text-lg"></span>
      </div>
    </div>
  </div>
<% end %>

CartItemsController の index アクションで定義した @cart_items をビューで受け取り、each メソッドを用いて繰り返し処理を行って一覧表示をしていますね。
注目すべきは、カート内商品の個数を増加させるプラスボタンの表示です。
商品の在庫数より多くの個数をカートに入れられては困りますので、cart_item.quantity >= cart_item.product.stocktrue のときは、プラスボタンを表示しないという記述をおこなっています。

それでは、まずカートに商品を一つも追加していない状態で、http://localhost:8000/cart_items にアクセスしてみましょう。

「No item」と表示され、商品一覧画面へのリンクが付与されたボタンが表示されれば OK です。

それでは、http://localhost:8000/products/1 にアクセスしてカートに追加するボタンを押してみましょう。id は作成した product の id を指定してください。

ボタンを押して、http://localhost:8000/cart_items に遷移し、カート内商品が新たに追加されていれば OK です。
また、ログから INSERT INTO "cart_items" という SQL 文が発行されていることを確認してください。発行されていれば、テーブルにデータが挿入されたということです。

次に、カート内商品の個数を変更してみましょう。
プラスボタン or マイナスボタンを押してください。

上記のように、カート内商品の個数が更新されれば OK です。
ログには、UPDATE "cart_items" というレコードを更新する SQL 文が発行されているはずです。

また、同じ商品を二度カートに追加したときの挙動も見てみましょう。

カート内商品のデータ数(ビューのテーブルの行数)は増えずに、カート内商品の quantity のみが増えていれば OK です。ログには、UPDATE "cart_items" というレコードを更新する SQL 文が発行されているはずです。

削除も実行してみましょう。
ゴミ箱ボタンを押すと「Are you sure?」というダイアログが出現し、「OK」とするとカート内商品が削除されます。

これで、カート機能の主要機能は完成しました。
しかし、ビューには空欄があったはずです。

  • 各カート内商品の合計金額(商品の価格 × 個数)
  • 全カート内商品の合計金額
  • 送料
  • 合計金額(全カート内商品の合計金額 + 送料)

上記四つの値を算出するために、メソッドを定義する必要があります。

合計金額を算出しよう

それでは、合計金額(全カート内商品の合計金額 + 送料)を算出してみましょう。
まずは、カート内商品一つ一つの合計金額を参照する必要があります。

各カート内商品の合計金額を算出しよう

各カート内商品の合計金額は、商品の価格 × 個数 とすれば OK です。
よって、この計算式をそのままコードに落とし込みましょう。
app/models/cart_item.rb を開いて下記コードを記述してください。

app/models/cart_item.rb
class CartItem < ApplicationRecord
  belongs_to :customer
  belongs_to :product
  
+ def line_total
+   product.price * quantity
+ end
end

これで、CartItemオブジェクト.line_total とすると、メソッドを実行したカート内商品の合計金額を算出することができます。

それでは、app/views/customer/cart_items/index.html.erb を開いて、このメソッドを実行してみましょう。下記の td タグの中で、line_total メソッドを実行して金額を表示してください。

app/views/customer/cart_items/index.html.erb
<td class="p-3">
  <span class="text-lg"><%= number_to_currency(cart_item.line_total, unit: "¥", strip_insignificant_zeros: true) %></span>
</td>

何度かすでに登場していますが、金額の表示には number_to_currency メソッドを使っています。
第一引数に金額、第二引数に通貨の単位、第三引数に小数点の非表示化を渡しています。

https://github.com/rails/rails/blob/984c3ef2775781d47efa9f541ce570daa2434a80/actionview/lib/action_view/helpers/number_helper.rb#L127

記述が完了したら、http://localhost:8000/cart_items にアクセスして金額を確認してみましょう。

商品の価格 × 個数 が正しく計算されていれば OK です。

これで、各カート内商品の合計金額が算出できたので、あとはこれらを合計すれば、カート内商品の合計金額を導くことができます。

カート内商品の合計金額を算出しよう

今回は、繰り返し処理を実装するのに便利な機能を持つメソッドである inject メソッド を使って、カート内商品の合計金額を算出します。

https://docs.ruby-lang.org/ja/latest/method/Enumerable/i/inject.html

簡単に inject メソッドについて解説します。
まず、使い方は下記の通りです。

配列オブジェクト.inject {|初期値, 要素| ブロック処理 }

例を一つ示します。

[1, 2, 3, 4].inject(0) { |sum, item| sum + item }

[1, 2, 3, 4] が配列オブジェクトですね。
inject(0) の 0 は、初期値(sum)を 0 とするという意味です。
この sum に配列の要素を足していきます。

最初のループで、

  • ブロック変数 item に配列の最初要素を格納する。
  • sum(初期値0)に item を足す。

という処理を行います。その結果、sum は 1 になります。
二回目のループでは、

  • ブロック変数 item に配列の二番目の要素を格納する。
  • sum(値は1)に item(値は2)を足す。

という処理を行います。その結果、sum は 3 になります。

このループを配列の要素がなくなるまで繰り返すため、配列の要素の合計値が算出できるというわけです。
このように inject メソッドを使うことで、配列の合計値を計算することができます。これは、カート内商品の合計値に活用できそうですね。

それでは、app/controllers/customer/cart_items_controller.rb を開いてください。
@total というインスタンス変数に、、カート内商品の合計値を格納しましょう。

app/controllers/customer/cart_items_controller.rb
class Customer::CartItemsController < ApplicationController
  before_action :authenticate_customer!
  before_action :set_cart_item, only: %i[increase decrease destroy]

  def index
    @cart_items = current_customer.cart_items
+   @total = @cart_items.inject(0) { |sum, cart_item| sum + cart_item.line_total }
  end

  def create
    increase_or_create(params[:cart_item][:product_id])
    redirect_to cart_items_path, notice: 'Successfully added product to your cart'
  end

  def increase
    @cart_item.increment!(:quantity, 1)
    redirect_to request.referer, notice: 'Successfully updated your cart'
  end

  def decrease
    decrease_or_destroy(@cart_item)
    redirect_to request.referer, notice: 'Successfully updated your cart'
  end

  def destroy
    @cart_item.destroy
    redirect_to request.referer, notice: 'Successfully deleted one cart item'
  end

  private

  def set_cart_item
    @cart_item = current_customer.cart_items.find(params[:id])
  end

  def increase_or_create(product_id)
    cart_item = current_customer.cart_items.find_by(product_id:)
    if cart_item
      cart_item.increment!(:quantity, 1)
    else
      current_customer.cart_items.build(product_id:).save
    end
  end

  def decrease_or_destroy(cart_item)
    if cart_item.quantity > 1
      cart_item.decrement!(:quantity, 1)
    else
      cart_item.destroy
    end
  end
end

先ほどの例において、[1, 2, 3, 4]@cart_items に置き換わっただけですね。
一回目のループでは、

  • @cart_items の最初の要素が cart_item に格納される。
  • 初期値0の sum に、cart_item.line_total が足される。

という処理が行われます。二回目以降のループも同様です。
このように inject メソッドを使うことで、合計値を簡単に算出することができます。

では、この @total をビューに渡してみましょう。
app/views/customer/cart_items/index.html.erb を開いて、subtotal の部分に下記コードを追加してください。

app/views/customer/cart_items/index.html.erb
<div class="flex justify-between my-6">
  <span class="font-semibold text-lg uppercase">subtotal</span>
  <span class="font-semibold text-lg"><%= number_to_currency(@total, unit: "¥", strip_insignificant_zeros: true) %></span>
</div>

記述が完了したら、http://localhost:8000/cart_items にアクセスして金額を確認してみましょう。

カート内商品の合計値 が正しく計算されていれば OK です。

送料を定義しよう

あとは、送料を定義してあげる必要があります。
今回、送料は一律 500 円にしますので、定数 として定義しましょう。

config/initializers/constants.rb を作成します。

$ touch config/initializers/constants.rb

ファイルが作成できたら、下記コードを記述しましょう。

config/initializers/constants.rb
POSTAGE = 500

Ruby では識別子の先頭が大文字である変数を、定数として認識します。
慣習としては識別子をすべて英大文字にし、単語の区切りを「_」とします。
一度値を代入した定数に再び値を代入しようとすると、警告メッセージが表示されます。

それでは、この POSTAGE という定数をビューで表示しましょう。
ついでに、カート内商品の合計金額と送料を足した値も表示しています。

app/views/customer/cart_items/index.html.erb
<div class="px-6 xl:w-1/3">
  <h1 class="font-semibold text-2xl border-b pb-6 text-center">
    Cart Summary
  </h1>
  <div class="flex justify-between my-6">
    <span class="font-semibold text-lg uppercase">subtotal</span>
    <span class="font-semibold text-lg"><%= number_to_currency(@total, unit: "¥", strip_insignificant_zeros: true) %></span>
  </div>
  <div class="flex justify-between my-6">
    <span class="font-semibold text-lg uppercase">postage</span>
    <span class="font-semibold text-lg"><%= number_to_currency(POSTAGE, unit: "¥", strip_insignificant_zeros: true) %></span>
  </div>
  <div class="flex justify-between my-6">
    <span class="font-semibold text-lg uppercase">total</span>
    <span class="font-semibold text-lg"><%= number_to_currency(@total + POSTAGE, unit: "¥", strip_insignificant_zeros: true) %></span>
  </div>
</div>

記述ができましたら、サーバを再起動 してください。
config/initializer は、その名の通り、アプリケーション起動時にロードされるためです。
「Ctrl + C」で、コンテナを停止したのち、docker-compose up でコンテナを立ち上げましょう。

http://localhost:8000/cart_items にアクセスして、送料・カート内商品の合計金額と送料を足した値、すなわち請求金額が表示されていれば OK です 🎉

ナビゲーションバーにカートのアイコンを追加しよう

最後に、ナビゲーションバーにカートのアイコンを追加します。
アイコンの右上に、現在のカート内商品の quantity の合計値も表示してあげましょう。

今回は、ビューの中で、カート内商品の quantity の合計値を算出するため、ヘルパー(helper)にメソッドを定義しましょう。app/helpers/customer/cart_items_helper.rb を開いて、下記コードを記述してください。

app/helpers/customer/cart_items_helper.rb
module Customer::CartItemsHelper
  def total_quantity(cart_items)
    cart_items.sum(:quantity)
  end
end

total_quantity というメソッドは、cart_items という配列を引数にとり、sum メソッドで quantity の合計値を算出しています。それでは、total_quantity メソッドをビューで使ってみましょう。app/views/layouts/application.html.erb を開いてください。
商品一覧画面のリンクが付与されているアイコンの下に、カートのアイコンを表示してあげます。

app/views/layouts/application.html.erb
<li class='text-white no-underline'>
  <%= link_to products_path, class: "p-4 relative border-2 border-transparent rounded-full focus:outline-none focus:text-gray-500 transition duration-150 ease-in-out flex" do %>
    <svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" clip-rule="evenodd">
      <path fill="white" d="M11.5 23l-8.5-4.535v-3.953l5.4 3.122 3.1-3.406v8.772zm1-.001v-8.806l3.162 3.343 5.338-2.958v3.887l-8.5 4.534zm-10.339-10.125l-2.161-1.244 3-3.302-3-2.823 8.718-4.505 3.215 2.385 3.325-2.385 8.742 4.561-2.995 2.771 2.995 3.443-2.242 1.241v-.001l-5.903 3.27-3.348-3.541 7.416-3.962-7.922-4.372-7.923 4.372 7.422 3.937v.024l-3.297 3.622-5.203-3.008-.16-.092-.679-.393v.002z"/>
    </svg>
  <% end %>
</li>
<li class='text-white no-underline'>
  <%= link_to cart_items_path, class: "p-4 relative border-2 border-transparent rounded-full focus:outline-none focus:text-gray-500 transition duration-150 ease-in-out flex" do %>
    <svg class="h-6 w-6" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" stroke="currentColor">
      <path fill="white" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"></path>
    </svg>
    <span class="absolute top-0 right-0">
      <div class="inline-flex items-center px-1.5 py-0.4 border-2 border-white rounded-full text-xs font-semibold leading-4 bg-red-500 text-white">
        <%= total_quantity(current_customer.cart_items) %>
      </div>
    </span>
  <% end %>
</li>

記述ができましたら、http://localhost:8000/cart_items にアクセスして、ナビゲーションバーの表示を確認してみましょう。

上記のように、現在のカート内商品の quantity の合計値が表示されていれば OK です。

これで、カート機能に関するビューの実装が完了しました。
最後に、RuboCop を実行し、必要であればコードを修正しましょう。

$ rubocop

今回は、RuboCop の静的解析で指摘事項が一つありました。

Inspecting 52 files
....................................C...............

Offenses:

config/initializers/constants.rb:1:14: C: [Correctable] Layout/TrailingEmptyLines: Final newline missing.
POSTAGE = 500


52 files inspected, 1 offense detected, 1 offense autocorrectable

どうやら、ファイルの最後には、空白行を追加する必要があるようです。
手動で修正しても良いですが、この程度の修正なら自動修正しても問題ありません。
下記コマンドを実行してください。

$ rubocop -a

-a は、--auto-correct-all の略です。

Inspecting 52 files
....................................C...............

Offenses:

config/initializers/constants.rb:1:14: C: [Corrected] Layout/TrailingEmptyLines: Final newline missing.
POSTAGE = 500


52 files inspected, 1 offense detected, 1 offense corrected

上記のように 1 offense corrected と表示されていれば自動修正が完了したということです。
試しに、再度静的解析を行ってみましょう。

$ rubocop

今度は、RuboCop の静的解析を全て PASS したようです。

Inspecting 52 files
....................................................

52 files inspected, no offenses detected

もしも他にも指摘事項があれば修正してください。
コミットしておきましょう。

$ git add . && git commit -m "Implementation of cart functionality"

おわりに

お疲れ様でした。
本 Chapter では、少々複雑なロジックが介在するカート機能の実装を行いました。
他言語で実装する際にも、本 Chapter で扱ったカート機能のロジックはほぼ同じです。
次の Chapter では、Stripe を使ってカート内の商品を購入する処理を実装していきます。