diff --git a/admin/app/components/solidus_admin/users/items/component.html.erb b/admin/app/components/solidus_admin/users/items/component.html.erb
new file mode 100644
index 0000000000..a80ee62527
--- /dev/null
+++ b/admin/app/components/solidus_admin/users/items/component.html.erb
@@ -0,0 +1,41 @@
+<%= page do %>
+ <%= page_header do %>
+ <%= page_header_back(solidus_admin.users_path) %>
+ <%= page_header_title(t(".title", email: @user.email)) %>
+
+ <%= page_header_actions do %>
+ <%= render component("ui/button").new(tag: :a, text: t(".create_order_for_user"), href: spree.new_admin_order_path(user_id: @user.id)) %>
+ <% end %>
+ <% end %>
+
+ <%= page_header do %>
+ <% tabs.each do |tab| %>
+ <%= render(component("ui/button").new(tag: :a, scheme: :ghost, text: tab[:text], 'aria-current': tab[:current], href: tab[:href])) %>
+ <% end %>
+ <% end %>
+
+ <%= page_with_sidebar do %>
+ <%= page_with_sidebar_main do %>
+ <%= render component('ui/panel').new(title: t(".items_purchased")) do %>
+ <% if @items.present? %>
+ <%= render component('ui/table').new(
+ id: stimulus_id,
+ data: {
+ class: model_class,
+ rows: rows,
+ columns: columns,
+ url: -> { row_url(_1.order) },
+ },
+ )%>
+ <% else %>
+ <%= t(".no_orders_found") %>
+ <%= render component("ui/button").new(tag: :a, text: t(".create_one"), href: spree.new_admin_order_path(user_id: @user.id)) %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+ <%= page_with_sidebar_aside do %>
+ <%= render component("users/last_login").new(user: @user) %>
+ <% end %>
+ <% end %>
+<% end %>
diff --git a/admin/app/components/solidus_admin/users/items/component.rb b/admin/app/components/solidus_admin/users/items/component.rb
new file mode 100644
index 0000000000..2a9e9aefa8
--- /dev/null
+++ b/admin/app/components/solidus_admin/users/items/component.rb
@@ -0,0 +1,171 @@
+# frozen_string_literal: true
+
+class SolidusAdmin::Users::Items::Component < SolidusAdmin::BaseComponent
+ include SolidusAdmin::Layout::PageHelpers
+
+ def initialize(user:, items:)
+ @user = user
+ @items = items
+ end
+
+ def form_id
+ @form_id ||= "#{stimulus_id}--form-#{@user.id}"
+ end
+
+ def tabs
+ [
+ {
+ text: t('.account'),
+ href: solidus_admin.user_path(@user),
+ current: false,
+ },
+ {
+ text: t('.addresses'),
+ href: solidus_admin.addresses_user_path(@user),
+ current: false,
+ },
+ {
+ text: t('.order_history'),
+ href: solidus_admin.orders_user_path(@user),
+ current: false,
+ },
+ {
+ text: t('.items'),
+ href: solidus_admin.items_user_path(@user),
+ current: true,
+ },
+ {
+ text: t('.store_credit'),
+ href: spree.admin_user_store_credits_path(@user),
+ current: false,
+ },
+ ]
+ end
+
+ def model_class
+ Spree::LineItem
+ end
+
+ def row_url(order)
+ spree.edit_admin_order_path(order)
+ end
+
+ def rows
+ @items
+ end
+
+ def columns
+ [
+ date_column,
+ image_column,
+ description_column,
+ price_column,
+ quantity_column,
+ total_column,
+ state_column,
+ number_column,
+ ]
+ end
+
+ def date_column
+ {
+ col: { class: "w-[8%]" },
+ header: :date,
+ data: ->(item) do
+ content_tag :div, l(item.order.created_at, format: :short), class: "text-sm"
+ end
+ }
+ end
+
+ def image_column
+ {
+ col: { class: "w-[8%]" },
+ header: tag.span('aria-label': Spree::Image.model_name.human, role: 'text'),
+ data: ->(item) do
+ image = (item.variant.gallery.images.first || item.variant.product.gallery.images.first) or return
+
+ render(
+ component('ui/thumbnail').new(
+ src: image.url(:small),
+ alt: item.product.name
+ )
+ )
+ end
+ }
+ end
+
+ def description_column
+ {
+ col: { class: "w-[24%]" },
+ header: t(".description_column_header"),
+ data: ->(item) { item_name_with_variant_and_sku(item) }
+ }
+ end
+
+ def price_column
+ {
+ col: { class: "w-[10%]" },
+ header: :price,
+ data: ->(item) do
+ content_tag :div, item.single_money.to_html
+ end
+ }
+ end
+
+ def quantity_column
+ {
+ col: { class: "w-[7%]" },
+ header: :qty,
+ data: ->(item) do
+ content_tag :div, item.quantity
+ end
+ }
+ end
+
+ def total_column
+ {
+ col: { class: "w-[10%]" },
+ header: t(".total_column_header"),
+ data: ->(item) do
+ content_tag :div, item.money.to_html
+ end
+ }
+ end
+
+ def state_column
+ {
+ col: { class: "w-[15%]" },
+ header: :state,
+ data: ->(item) do
+ color = {
+ 'complete' => :green,
+ 'returned' => :red,
+ 'canceled' => :blue,
+ 'cart' => :graphite_light,
+ }[item.order.state] || :yellow
+ component('ui/badge').new(name: item.order.state.humanize, color: color)
+ end
+ }
+ end
+
+ def number_column
+ {
+ col: { class: "w-[18%]" },
+ header: t(".number_column_header"),
+ data: ->(item) do
+ content_tag :div, item.order.number, class: "font-semibold text-sm"
+ end
+ }
+ end
+
+ private
+
+ def item_name_with_variant_and_sku(item)
+ content = []
+ content << item.product.name
+ content << "(#{item.variant.options_text})" if item.variant.option_values.any?
+ content << "#{t('spree.sku')}: #{item.variant.sku}" if item.variant.sku.present?
+
+ safe_join([content_tag(:div, content.join("
").html_safe, class: "text-sm")])
+ end
+end
diff --git a/admin/app/components/solidus_admin/users/items/component.yml b/admin/app/components/solidus_admin/users/items/component.yml
new file mode 100644
index 0000000000..2659e722c0
--- /dev/null
+++ b/admin/app/components/solidus_admin/users/items/component.yml
@@ -0,0 +1,16 @@
+en:
+ title: "Users / %{email} / Items Purchased"
+ account: Account
+ addresses: Addresses
+ order_history: Order History
+ items: Items
+ store_credit: Store Credit
+ last_active: Last Active
+ create_order_for_user: Create order for this user
+ items_purchased: Items Purchased
+ no_orders_found: No Orders found.
+ create_one: Create One
+ back: Back
+ number_column_header: "Order #"
+ description_column_header: "Description"
+ total_column_header: "Total"
diff --git a/admin/app/controllers/solidus_admin/users_controller.rb b/admin/app/controllers/solidus_admin/users_controller.rb
index 8b1a785859..7e7d181a1b 100644
--- a/admin/app/controllers/solidus_admin/users_controller.rb
+++ b/admin/app/controllers/solidus_admin/users_controller.rb
@@ -5,7 +5,7 @@ class UsersController < SolidusAdmin::BaseController
include SolidusAdmin::ControllerHelpers::Search
include Spree::Core::ControllerHelpers::StrongParameters
- before_action :set_user, only: [:edit, :addresses, :update_addresses, :orders]
+ before_action :set_user, only: [:edit, :addresses, :update_addresses, :orders, :items]
search_scope(:all, default: true)
search_scope(:customers) { _1.left_outer_joins(:role_users).where(role_users: { id: nil }) }
@@ -58,6 +58,14 @@ def orders
end
end
+ def items
+ set_items
+
+ respond_to do |format|
+ format.html { render component('users/items').new(user: @user, items: @items) }
+ end
+ end
+
def edit
respond_to do |format|
format.html { render component('users/edit').new(user: @user) }
@@ -107,6 +115,13 @@ def set_orders
@orders = @search.result.page(params[:page]).per(Spree::Config[:admin_products_per_page])
end
+ def set_items
+ params[:q] ||= {}
+ @search = Spree::Order.reverse_chronological.includes(line_items: { variant: [:product, { option_values: :option_type }] }).ransack(params[:q].merge(user_id_eq: @user.id))
+ @orders = @search.result.page(params[:page]).per(Spree::Config[:admin_products_per_page])
+ @items = @orders&.map(&:line_items)&.flatten
+ end
+
def authorization_subject
Spree.user_class
end
diff --git a/admin/config/routes.rb b/admin/config/routes.rb
index d33b23d60c..862ee70212 100644
--- a/admin/config/routes.rb
+++ b/admin/config/routes.rb
@@ -50,6 +50,7 @@
get :addresses
put :update_addresses
get :orders
+ get :items
end
end
diff --git a/admin/spec/features/users_spec.rb b/admin/spec/features/users_spec.rb
index 0a4f450c98..b6ac4e097a 100644
--- a/admin/spec/features/users_spec.rb
+++ b/admin/spec/features/users_spec.rb
@@ -206,4 +206,50 @@
end
end
end
+
+ context "when viewing a user's purchased items" do
+ context "when a user has no purchased items" do
+ before do
+ create(:user, email: "customer@example.com")
+ visit "/admin/users"
+ find_row("customer@example.com").click
+ click_on "Items"
+ end
+
+ it "shows the purchased items page" do
+ expect(page).to have_content("Users / customer@example.com / Items Purchased")
+ expect(page).to have_content("Lifetime Stats")
+ expect(page).to have_content("Items Purchased")
+ expect(page).to be_axe_clean
+ end
+
+ it "shows the appropriate content" do
+ expect(page).to have_content("No Orders found.")
+ end
+ end
+
+ context "when a user has ordered before" do
+ before do
+ create(:order_with_line_items, user: create(:user, email: "loyal_customer@example.com"))
+ visit "/admin/users"
+ find_row("loyal_customer@example.com").click
+ click_on "Items"
+ end
+
+ it "shows the purchased items page" do
+ expect(page).to have_content("Users / loyal_customer@example.com / Items Purchased")
+ expect(page).to have_content("Lifetime Stats")
+ expect(page).to have_content("Items Purchased")
+ expect(page).to be_axe_clean
+ end
+
+ it "lists the purchased items" do
+ expect(page).to have_content(/R\d+/) # Matches on any order number.
+ expect(page).to have_content("Description")
+ expect(page).to have_content("Qty")
+ expect(page).to have_content("State")
+ expect(page).not_to have_content("No Orders found.")
+ end
+ end
+ end
end
diff --git a/admin/spec/requests/solidus_admin/users_spec.rb b/admin/spec/requests/solidus_admin/users_spec.rb
index 882342189b..c2ca7c88e2 100644
--- a/admin/spec/requests/solidus_admin/users_spec.rb
+++ b/admin/spec/requests/solidus_admin/users_spec.rb
@@ -69,6 +69,16 @@
end
end
+ describe "GET /items" do
+ let!(:order) { create(:order_with_line_items, user: user) }
+
+ it "renders the items template and displays the user's purchased items" do
+ get solidus_admin.items_user_path(user)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include(order.number)
+ end
+ end
+
describe "DELETE /destroy" do
it "deletes the user and redirects to the index page with a 303 See Other status" do
# Ensure the user exists prior to deletion