Chapter 10

商品に関する機能を実装しよう(顧客)

FarStep
FarStep
2022.12.10に更新

はじめに

本 Chapter では、顧客側の商品に関する実装を行います。
顧客側の実装になりますので、管理者が登録した商品を閲覧する機能のみを実装していきます。

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

本 Chapter では、いきなりコントローラの作成から入ります。
モデルやテーブルの作成は不要です。なぜなら、前 Chapter で作成済みだからです。

コンテナが立ち上がっていることが確認できましたら、ecommerce_web コンテナに入ります。
その後、下記コマンドを実行して、必要なコントローラを作成しましょう。

$ rails g controller customer/products index show --no-helper

今回 customer ディレクトリ配下に ProductsController を作成するために、customer/products としています。前 Chapter でも、ProductsController を作成しましたが、異なる名前空間に属しているため、両者が競合することはありません

続いて、ルーティングの設定を行います。
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]
+ end

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

上記のように

scope module: :customer do
end

で囲むと、ファイル構成は「customer/」となりますが、URL は「customer/」となりません。
少しわかりずらいので、生成されたパスを確認してみましょう。

$ rails routes | grep products | grep customer

下記のように、URL や名前付きパスには「customer」は付与されていません。しかし、呼び出されるコントローラは「customer」配下のコントローラが指定されています。

products GET    /products(.:format)        customer/products#index
 product GET    /products/:id(.:format)    customer/products#show

なぜこのようなパスにしたかというと、管理者側の URL に「admin/」をつけているため、顧客側の URL に「customer/」をつけなくても両者を区別できるためです。

それでは次に、コントローラの各アクションの中身を記述していきます。
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])
  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

show アクションについては、パラメータの id をもとに product を一件取得するという一般的な処理が記述されています。
index アクションについては、ソート機能を見越してパラメータの値によって product の取得方法を変化させています。

例えば、http://localhost:8000/products?latest=true という URL にアクセスした場合、params[:latest]true となりますので、get_products メソッド内の二つ目の条件に合致します。すると、Product.latest が実行され、作成された順に product を取得することができます。
他の条件も同様です。
ちなみに @sort というインスタンス変数は、現在適用されているソートの種類を識別するために使います。

それでは、

  • latest スコープ
  • price_high_to_low スコープ
  • price_low_to_high スコープ

を定義しましょう。

latest というスコープに関しては、全てのモデルについて使用するものですので、app/models/application_record.rb に記述しましょう。

app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
+ scope :latest, -> { order(created_at: :desc) }
end

ここで登場した scope とは、クラスメソッドを使う際、可読性を保つために使います。
モデル名.latest とすると、created_at の値を 降順 で並び替えてくれます。

次に、price_high_to_lowprice_low_to_high というスコープを定義しましょう。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) }
end

price_high_to_low は価格を降順に、price_low_to_high は価格を昇順にして product を取得することができます。

これで、http://localhost:8000/products にアクセスするときに、パラメータの値によって、ソートの仕方を変えることができます。

ビューを作成しよう

それでは、最後に Tailwind CSS を使ってビューを作成していきましょう。
まずは、商品の一覧画面です。app/views/customer/products/index.html.erb を開いて下記コードを記述してください。

app/views/customer/products/index.html.erb
<div class="mb-8 text-center">
  <span class="text-3xl font-bold">
    Products
  </span>
</div>
<div class="flex flex-wrap justify-center md:justify-start px-5">
  <div class="mb-3 w-fit text-md font-medium mr-2 px-4 py-1.5 rounded <%= @sort === "latest" ? "bg-gray-800 text-gray-100" : "bg-gray-100 text-gray-800" %>">
    <%= link_to 'Latest', products_path(latest: true) %>
  </div>
  <div class="mb-3 w-fit text-md font-medium mr-2 px-4 py-1.5 rounded <%= @sort === "price_high_to_low" ? "bg-gray-800 text-gray-100" : "bg-gray-100 text-gray-800" %>">
    <%= link_to 'Price: High to Low', products_path(price_high_to_low: true) %>
  </div>
  <div class="mb-3 w-fit text-md font-medium mr-2 px-4 py-1.5 rounded <%= @sort === "price_low_to_high" ? "bg-gray-800 text-gray-100" : "bg-gray-100 text-gray-800" %>">
    <%= link_to 'Price: Low to High', products_path(price_low_to_high: true) %>
  </div>
</div>
<div class='flex flex-wrap justify-center'>
  <% @products.each do |product| %>
    <div class="w-full max-w-sm rounded-lg xl:w-4/12 md:w-6/12">
      <%= link_to product_path(product) do %>
        <%= image_tag product.image, class: "p-5 object-cover" %>
        <div class="px-5 pb-5">
          <p class="text-3xl font-semibold tracking-tight text-gray-900 mb-4"><%= product.name %></p>
          <div class="flex justify-between items-center">
            <span class="text-3xl font-bold text-gray-900"><%= number_to_currency(product.price, unit: "¥", strip_insignificant_zeros: true) %></span>
            <% if product.stock > 0 %>
              <span class="bg-blue-100 text-blue-800 text-xs font-semibold p-2 rounded">In stock</span>
            <% else %>
              <span class="bg-red-100 text-red-800 text-xs font-semibold p-2 rounded">Out of stock</span>
            <% end %>
          </div>
        </div>
      <% end %>
    </div>
  <% end %>
</div>

下記の三つのリンクはソート用のリンクになります。
URL にそれぞれ異なるパラメータを付与しています。

<div class="mb-3 w-fit text-md font-medium mr-2 px-4 py-1.5 rounded <%= @sort === "latest" ? "bg-gray-800 text-gray-100" : "bg-gray-100 text-gray-800" %>">
  <%= link_to 'Latest', products_path(latest: true) %>
</div>
<div class="mb-3 w-fit text-md font-medium mr-2 px-4 py-1.5 rounded <%= @sort === "price_high_to_low" ? "bg-gray-800 text-gray-100" : "bg-gray-100 text-gray-800" %>">
  <%= link_to 'Price: High to Low', products_path(price_high_to_low: true) %>
</div>
<div class="mb-3 w-fit text-md font-medium mr-2 px-4 py-1.5 rounded <%= @sort === "price_low_to_high" ? "bg-gray-800 text-gray-100" : "bg-gray-100 text-gray-800" %>">
  <%= link_to 'Price: Low to High', products_path(price_low_to_high: true) %>
</div>

ビューの記述が完了しましたら、http://localhost:8000/products にアクセスして商品一覧画面を表示してみましょう。顧客の商品に関するページは特にアクセス制限を施していないため、ログインしていなくてもアクセスできるはずです。

下記のように商品の一覧が表示されていれば OK です。

試しにソートボタンをクリックしてみましょう。
「Price: High to Low」ボタンをクリックすると、価格が安い順に商品が並びます。

続いて、商品の詳細画面を作成しましょう。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>
      </div>
    </div>
  </div>
</div>

http://localhost:8000/products/1 にアクセスして下記のような画面が表示されていれば OK です。id は存在する product の id を指定してください。

商品に関するビューが完成しましたので、商品一覧画面へのリンクをナビゲーションバーに追加してあげましょう。
app/views/layouts/application.html.erb を開いて、<% elsif customer_signed_in? %> 内に、下記コードを追加してください。

app/views/layouts/application.html.erb
<% elsif customer_signed_in? %>
  <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='no-underline'>
    <div class="flex items-center justify-center">
      <div class="relative inline-block text-left dropdown">
        <button class="inline-flex justify-center w-full px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition duration-150 ease-in-out  rounded-md" type="button" aria-haspopup="true" aria-expanded="true" aria-controls="headlessui-menu-items-117">
          <svg class="h-6 w-6" id="dropdownDividerButton" data-dropdown-toggle="dropdownDivider" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
            <path fill="white" d="M288 320a224 224 0 1 0 448 0 224 224 0 1 0-448 0zm544 608H160a32 32 0 0 1-32-32v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 0 1-32 32z">
            </path>
          </svg>
        </button>
        <div class="opacity-0 invisible dropdown-menu transition-all duration-300 transform origin-top-right -translate-y-2 scale-95">
          <div class="absolute right-0 w-56 mt-2 origin-top-right bg-white border border-gray-200 divide-y divide-gray-100 rounded-md shadow-lg outline-none" aria-labelledby="headlessui-menu-button-1" id="headlessui-menu-items-117" role="menu">
            <div class="px-4 py-3">         
              <p class="text-sm leading-5">Signed in as</p>
              <p class="text-sm font-medium leading-5 text-gray-900 truncate"><%= current_customer.email %></p>
            </div>
            <div class="py-1">
              <%= link_to edit_customer_registration_path, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                Account settings
              <% end %>
            </div>
            <div class="py-1">
              <%= link_to destroy_customer_session_path, data: { turbo_method: :delete }, class: "text-gray-700 flex justify-between w-full px-4 py-2 text-sm leading-5 text-left" do %>
                Sign out
              <% end %>
            </div>
          </div>
        </div>
      </div>
    </div>
  </li>
<% else %>

上記のコードを記述すると、ログインしている顧客のナビゲーションバーに、商品一覧へのリンクが付与されたアイコンが表示されるはずです。

最後に、トップページの「Find Products」ボタンのリンクを商品一覧画面へのパスに変更しておきましょう。
app/views/pages/home.html.erb を開いてパスを修正してください。

app/views/pages/home.html.erb
<div class='pt-32 pb-12 md:pt-40 md:pb-20'>
  <div class='pb-12 text-center md:pb-16'>
    <h1 class='leading-tighter mb-4 text-7xl font-extrabold tracking-tighter md:text-8xl'>
      <span class='p-2 bg-gradient-to-r from-purple-600 to-blue-500 bg-clip-text text-transparent'>
        ECommerce
      </span>
    </h1>
    <div class='mx-auto max-w-3xl'>
      <p class='mb-8 text-xl'>You will find what you are looking for.</p>
      <div class='flex justify-center mt-5'>
        <div class='mt-3'>
          <%= 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>
    </div>
  </div>
</div>

それでは、RuboCop を実行し、必要であればコードを修正しましょう。

& rubocop

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

Inspecting 48 files
................................................

48 files inspected, no offenses detected

もしも指摘事項があれば修正してください。
これで、顧客側の商品に関する実装は完了しました。
コミットしておきましょう。

$ git add . && git commit -m "Implementation on the customer's side of the product"

おわりに

お疲れ様でした。
本 Chapter では、顧客側の商品に関する機能を実装しました。
これで商品に関する実装は全て終了しましたので、次の Chapter ではカートに関する機能を実装します。