diff --git a/apps/block_scout_web/assets/css/components/_tile.scss b/apps/block_scout_web/assets/css/components/_tile.scss
index 9f07317c08..d7a83abdf0 100644
--- a/apps/block_scout_web/assets/css/components/_tile.scss
+++ b/apps/block_scout_web/assets/css/components/_tile.scss
@@ -63,6 +63,28 @@
}
}
+ &-unique-token {
+ border-left: 4px solid $orange;
+ padding: 35px 0;
+
+ .tile-label {
+ color: $orange;
+ }
+ }
+
+ &-unique-token-image{
+ border-left: 4px solid $orange;
+ padding: 0;
+
+ .tile-label {
+ color: $orange;
+ }
+
+ & .tile-content {
+ padding: 45px 0;
+ }
+ }
+
&-internal-transaction {
border-left: 4px solid $teal;
@@ -150,3 +172,8 @@
margin: 0;
}
}
+
+.tile-image {
+ max-width: 140px;
+ max-height: 140px;
+}
diff --git a/apps/block_scout_web/lib/block_scout_web/chain.ex b/apps/block_scout_web/lib/block_scout_web/chain.ex
index c41229acec..d65d11de7d 100644
--- a/apps/block_scout_web/lib/block_scout_web/chain.ex
+++ b/apps/block_scout_web/lib/block_scout_web/chain.ex
@@ -29,6 +29,10 @@ defmodule BlockScoutWeb.Chain do
@page_size 50
@default_paging_options %PagingOptions{page_size: @page_size + 1}
+ def default_paging_options do
+ @default_paging_options
+ end
+
def current_filter(%{paging_options: paging_options} = params) do
params
|> Map.get("filter")
diff --git a/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex
new file mode 100644
index 0000000000..a6a19a8523
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/controllers/tokens/inventory_controller.ex
@@ -0,0 +1,52 @@
+defmodule BlockScoutWeb.Tokens.InventoryController do
+ use BlockScoutWeb, :controller
+
+ alias Explorer.Chain
+ alias Explorer.Chain.TokenTransfer
+
+ import BlockScoutWeb.Chain, only: [split_list_by_page: 1, default_paging_options: 0]
+
+ def index(conn, %{"token_id" => address_hash_string} = params) do
+ with {:ok, address_hash} <- Chain.string_to_address_hash(address_hash_string),
+ {:ok, token} <- Chain.token_from_address_hash(address_hash) do
+ unique_tokens =
+ Chain.address_to_unique_tokens(
+ token.contract_address_hash,
+ unique_tokens_paging_options(params)
+ )
+
+ {unique_tokens_paginated, next_page} = split_list_by_page(unique_tokens)
+
+ render(
+ conn,
+ "index.html",
+ token: token,
+ unique_tokens: unique_tokens_paginated,
+ total_token_transfers: Chain.count_token_transfers_from_token_hash(address_hash),
+ total_token_holders: Chain.count_token_holders_from_token_hash(address_hash),
+ next_page_params: unique_tokens_next_page(next_page, unique_tokens_paginated, params)
+ )
+ else
+ :error ->
+ not_found(conn)
+
+ {:error, :not_found} ->
+ not_found(conn)
+ end
+ end
+
+ defp unique_tokens_paging_options(%{"unique_token" => token_id}),
+ do: [paging_options: %{default_paging_options() | key: {token_id}}]
+
+ defp unique_tokens_paging_options(_params), do: [paging_options: default_paging_options()]
+
+ defp unique_tokens_next_page([], _list, _params), do: nil
+
+ defp unique_tokens_next_page(_, list, params) do
+ Map.merge(params, paging_params(List.last(list)))
+ end
+
+ defp paging_params(%TokenTransfer{token_id: token_id}) do
+ %{"unique_token" => Decimal.to_integer(token_id)}
+ end
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/router.ex b/apps/block_scout_web/lib/block_scout_web/router.ex
index 93e6532cd3..cc18314f4b 100644
--- a/apps/block_scout_web/lib/block_scout_web/router.ex
+++ b/apps/block_scout_web/lib/block_scout_web/router.ex
@@ -135,6 +135,13 @@ defmodule BlockScoutWeb.Router do
only: [:index],
as: :holder
)
+
+ resources(
+ "/inventory",
+ Tokens.InventoryController,
+ only: [:index],
+ as: :inventory
+ )
end
resources(
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/_token.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/_token.html.eex
new file mode 100644
index 0000000000..88c36e5158
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/_token.html.eex
@@ -0,0 +1,28 @@
+
+
+
+
+
+ <%= gettext "Unique Token" %>
+
+
+
+
+ <%= gettext "Token ID" %>:
+
+ <%= @token_transfer.token_id %>
+
+
+
+
+ <%= gettext "Owner Address" %>:
+
+ <%= render BlockScoutWeb.AddressView,
+ "_link.html",
+ address: @token_transfer.to_address,
+ contract: false %>
+
+
+
+
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/index.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/index.html.eex
new file mode 100644
index 0000000000..8b2fdcb5b7
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/templates/tokens/inventory/index.html.eex
@@ -0,0 +1,42 @@
+
+ <%= render(
+ OverviewView,
+ "_details.html",
+ token: @token,
+ total_token_transfers: @total_token_transfers,
+ total_token_holders: @total_token_holders,
+ conn: @conn
+ ) %>
+
+
+
+
+
+
+
<%= gettext "Inventory" %>
+
+ <%= if Enum.any?(@unique_tokens) do %>
+ <%= for token_transfer <- @unique_tokens do %>
+ <%= render "_token.html", token_transfer: token_transfer %>
+ <% end %>
+ <% else %>
+
+
+ <%= gettext "There are no tokens." %>
+
+
+ <% end %>
+
+ <%= if @next_page_params do %>
+ <%= link(
+ gettext("Next Page"),
+ class: "button button-secondary button-small float-right mt-4",
+ to: token_inventory_path(@conn, :index, @token.contract_address_hash, @next_page_params)
+ ) %>
+ <% end %>
+
+
+
+
diff --git a/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex b/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex
index b5dc0dc9b8..b2ca5f3d18 100644
--- a/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex
+++ b/apps/block_scout_web/lib/block_scout_web/templates/tokens/overview/_tabs.html.eex
@@ -25,6 +25,16 @@
to: token_holder_path(@conn, :index, @token.contract_address_hash)
) %>
+
+ <%= if display_inventory?(@token) do %>
+
+ <%= link(
+ gettext("Inventory"),
+ class: "nav-link #{tab_status("inventory", @conn.request_path)}",
+ to: token_inventory_path(@conn, :index, @token.contract_address_hash)
+ ) %>
+
+ <% end %>
@@ -56,6 +66,14 @@
class: "dropdown-item #{tab_status("token_holders", @conn.request_path)}",
to: token_holder_path(@conn, :index, @token.contract_address_hash)
) %>
+
+ <%= if display_inventory?(@token) do %>
+ <%= link(
+ gettext("Inventory"),
+ class: "dropdown-item #{tab_status("inventory", @conn.request_path)}",
+ to: token_inventory_path(@conn, :index, @token.contract_address_hash)
+ ) %>
+ <% end %>
diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex
new file mode 100644
index 0000000000..6c3f3a6845
--- /dev/null
+++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/inventory_view.ex
@@ -0,0 +1,5 @@
+defmodule BlockScoutWeb.Tokens.InventoryView do
+ use BlockScoutWeb, :view
+
+ alias BlockScoutWeb.Tokens.OverviewView
+end
diff --git a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex
index 80bb51a04e..f36bca8b80 100644
--- a/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex
+++ b/apps/block_scout_web/lib/block_scout_web/views/tokens/overview_view.ex
@@ -4,7 +4,7 @@ defmodule BlockScoutWeb.Tokens.OverviewView do
alias Explorer.Chain.Token
alias BlockScoutWeb.Tokens.TransferView
- @tabs ["token_transfers", "token_holders", "read_contract"]
+ @tabs ["token_transfers", "token_holders", "read_contract", "inventory"]
def decimals?(%Token{decimals: nil}), do: false
def decimals?(%Token{decimals: _}), do: true
@@ -34,4 +34,8 @@ defmodule BlockScoutWeb.Tokens.OverviewView do
defp tab_name(["token_transfers"]), do: gettext("Token Transfers")
defp tab_name(["token_holders"]), do: gettext("Token Holders")
defp tab_name(["read_contract"]), do: gettext("Read Contract")
+ defp tab_name(["inventory"]), do: gettext("Inventory")
+
+ def display_inventory?(%Token{type: "ERC-721"}), do: true
+ def display_inventory?(_), do: false
end
diff --git a/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs b/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs
index 5f18eff56a..22f1e81299 100644
--- a/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs
+++ b/apps/block_scout_web/test/block_scout_web/views/tokens/overview_view_test.exs
@@ -64,4 +64,18 @@ defmodule BlockScoutWeb.Tokens.OverviewViewTest do
assert OverviewView.current_tab_name(read_contract_path) == "Read Contract"
end
end
+
+ describe "display_inventory?/1" do
+ test "returns true when token is unique" do
+ token = insert(:token, type: "ERC-721")
+
+ assert OverviewView.display_inventory?(token) == true
+ end
+
+ test "returns false when token is not unique" do
+ token = insert(:token, type: "ERC-20")
+
+ assert OverviewView.display_inventory?(token) == false
+ end
+ end
end