- <.link
- navigate={
- PhoenixKit.Utils.Routes.path(
- "/admin/entities/#{get_entity_slug(@entities, data_record.entity_id)}/data/#{data_record.uuid}"
- )
- }
- class="flex-1 hover:text-primary transition-colors cursor-pointer"
- >
- <%!-- Title and Entity Info --%>
-
-
{data_record.title}
- <%= if !@selected_entity do %>
-
- {get_entity_name(@entities, data_record.entity_id)}
-
- <% end %>
-
+
+
+
+
+ <.link
+ navigate={
+ PhoenixKit.Utils.Routes.path(
+ "/admin/entities/#{get_entity_slug(@entities, data_record.entity_id)}/data/#{data_record.uuid}"
+ )
+ }
+ class="flex-1 hover:text-primary transition-colors cursor-pointer"
+ >
+ <%!-- Title and Entity Info --%>
+
+
{data_record.title}
+ <%= if !@selected_entity do %>
+
+ {get_entity_name(@entities, data_record.entity_id)}
+
+ <% end %>
+
- <%!-- Slug --%>
- <%= if data_record.slug do %>
-
- <.icon name="hero-link" class="w-4 h-4 inline mr-1" />
- {data_record.slug}
-
- <% end %>
+ <%!-- Slug --%>
+ <%= if data_record.slug do %>
+
+ <.icon name="hero-link" class="w-4 h-4 inline mr-1" />
+ {data_record.slug}
+
+ <% end %>
- <%!-- Data Preview --%>
- <%= if data_record.data && map_size(data_record.data) > 0 do %>
-
- {format_data_preview(data_record.data)}
-
- <% end %>
-
+ <%!-- Category --%>
+ <%= case get_in(data_record.data, ["category"]) do %>
+ <% nil -> %>
+
+ <.icon name="hero-tag" class="w-3 h-3 mr-1" />
+ {gettext("Uncategorized")}
+
+ <% "" -> %>
+
+ <.icon name="hero-tag" class="w-3 h-3 mr-1" />
+ {gettext("Uncategorized")}
+
+ <% category -> %>
+
+ <.icon name="hero-tag" class="w-3 h-3 mr-1" />
+ {category}
+
+ <% end %>
- <%!-- Status Badge --%>
-
-
- <.icon name={status_icon(data_record.status)} class="w-3 h-3 mr-1" />
- {status_label(data_record.status)}
-
+ <%!-- Data Preview --%>
+ <%= if data_record.data && map_size(data_record.data) > 0 do %>
+
+ {format_data_preview(data_record.data)}
+
+ <% end %>
+
- <%!-- Status Toggle Button --%>
-
- <.icon name="hero-arrow-path" class="w-3 h-3" />
-
-
-
+ <%!-- Status Badge --%>
+
+
+ <.icon name={status_icon(data_record.status)} class="w-3 h-3 mr-1" />
+ {status_label(data_record.status)}
+
- <%!-- Metadata Row --%>
-
- <%= if data_record.creator do %>
-
- <.icon name="hero-user" class="w-3 h-3 inline mr-1" />
- {data_record.creator.email}
-
- <% end %>
-
- <.icon name="hero-calendar" class="w-3 h-3 inline mr-1" />
- {gettext("Created")} {PhoenixKit.Utils.Date.format_date_with_user_format(
- data_record.date_created
- )}
-
- <%= if data_record.date_updated != data_record.date_created do %>
-
- <.icon name="hero-clock" class="w-3 h-3 inline mr-1" />
- {gettext("Updated")} {PhoenixKit.Utils.Date.format_date_with_user_format(
- data_record.date_updated
- )}
-
- <% end %>
+ <%!-- Status Toggle Button --%>
+
+ <.icon name="hero-arrow-path" class="w-3 h-3" />
+
+
+
+
+ <%!-- Metadata Row --%>
+
+ <%= if data_record.creator do %>
+
+ <.icon name="hero-user" class="w-3 h-3 inline mr-1" />
+ {data_record.creator.email}
+
+ <% end %>
+
+ <.icon name="hero-calendar" class="w-3 h-3 inline mr-1" />
+ {gettext("Created")} {PhoenixKit.Utils.Date.format_date_with_user_format(
+ data_record.date_created
+ )}
+
+ <%= if data_record.date_updated != data_record.date_created do %>
+
+ <.icon name="hero-clock" class="w-3 h-3 inline mr-1" />
+ {gettext("Updated")} {PhoenixKit.Utils.Date.format_date_with_user_format(
+ data_record.date_updated
+ )}
+
+ <% end %>
+
+
<%!-- Actions --%>
diff --git a/lib/modules/shop/events.ex b/lib/modules/shop/events.ex
index 2f09d6b4..1c3de6d1 100644
--- a/lib/modules/shop/events.ex
+++ b/lib/modules/shop/events.ex
@@ -277,6 +277,27 @@ defmodule PhoenixKit.Modules.Shop.Events do
broadcast(@categories_topic, {:category_deleted, category_id})
end
+ @doc """
+ Broadcasts bulk category status changed event.
+ """
+ def broadcast_categories_bulk_status_changed(category_ids, status) do
+ broadcast(@categories_topic, {:categories_bulk_status_changed, category_ids, status})
+ end
+
+ @doc """
+ Broadcasts bulk category parent changed event.
+ """
+ def broadcast_categories_bulk_parent_changed(category_ids, parent_uuid) do
+ broadcast(@categories_topic, {:categories_bulk_parent_changed, category_ids, parent_uuid})
+ end
+
+ @doc """
+ Broadcasts bulk category deleted event.
+ """
+ def broadcast_categories_bulk_deleted(category_ids) do
+ broadcast(@categories_topic, {:categories_bulk_deleted, category_ids})
+ end
+
# ============================================
# INVENTORY BROADCAST FUNCTIONS
# ============================================
diff --git a/lib/modules/shop/shop.ex b/lib/modules/shop/shop.ex
index 9927aa65..f2b36a90 100644
--- a/lib/modules/shop/shop.ex
+++ b/lib/modules/shop/shop.ex
@@ -309,12 +309,18 @@ defmodule PhoenixKit.Modules.Shop do
FROM phoenix_kit_shop_products p,
jsonb_array_elements_text(COALESCE(p.metadata->'_option_values'->$1, '[]'::jsonb)) AS val
WHERE p.status = 'active'
- #{if category_uuid, do: "AND p.category_uuid = $2::uuid", else: ""}
+ #{if category_uuid, do: "AND p.category_uuid = $2", else: ""}
GROUP BY val
ORDER BY count DESC
"""
- params = if category_uuid, do: [key, category_uuid], else: [key]
+ params =
+ if category_uuid do
+ {:ok, uuid_bin} = Ecto.UUID.dump(category_uuid)
+ [key, uuid_bin]
+ else
+ [key]
+ end
case repo().query(sql, params) do
{:ok, %{rows: rows}} ->
@@ -732,6 +738,20 @@ defmodule PhoenixKit.Modules.Shop do
|> repo().all()
end
+ @doc """
+ Returns a map of category_id => product_count for all categories.
+ """
+ def product_counts_by_category do
+ Product
+ |> where([p], not is_nil(p.category_id))
+ |> group_by([p], p.category_id)
+ |> select([p], {p.category_id, count(p.id)})
+ |> repo().all()
+ |> Map.new()
+ rescue
+ _ -> %{}
+ end
+
@doc """
Lists root categories (no parent).
"""
@@ -926,6 +946,112 @@ defmodule PhoenixKit.Modules.Shop do
Category.changeset(category, attrs)
end
+ @doc """
+ Bulk update category status.
+ Returns count of updated categories.
+ """
+ def bulk_update_category_status(ids, status) when is_list(ids) and is_binary(status) do
+ query =
+ if ids_are_uuids?(ids) do
+ Category |> where([c], c.uuid in ^ids)
+ else
+ Category |> where([c], c.id in ^ids)
+ end
+
+ {count, _} =
+ query
+ |> repo().update_all(set: [status: status, updated_at: DateTime.utc_now()])
+
+ if count > 0 do
+ Events.broadcast_categories_bulk_status_changed(ids, status)
+ end
+
+ count
+ end
+
+ @doc """
+ Bulk update category parent.
+ Returns count of updated categories. Excludes the target parent from the update set
+ to prevent self-reference. Uses a single UPDATE with subquery to resolve parent_id.
+ """
+ def bulk_update_category_parent(ids, parent_uuid) when is_list(ids) do
+ # Exclude the target parent from update set to prevent self-reference
+ ids_to_update = if parent_uuid, do: Enum.reject(ids, &(&1 == parent_uuid)), else: ids
+
+ if ids_to_update == [] do
+ 0
+ else
+ now = DateTime.utc_now()
+
+ {count, _} =
+ if is_nil(parent_uuid) do
+ # Make root — set both to nil
+ Category
+ |> where([c], c.uuid in ^ids_to_update)
+ |> repo().update_all(set: [parent_id: nil, parent_uuid: nil, updated_at: now])
+ else
+ # Resolve parent_id in one query, then bulk update
+ parent =
+ Category
+ |> where([c], c.uuid == ^parent_uuid)
+ |> select([c], %{id: c.id})
+ |> repo().one()
+
+ if parent do
+ Category
+ |> where([c], c.uuid in ^ids_to_update)
+ |> repo().update_all(
+ set: [parent_id: parent.id, parent_uuid: parent_uuid, updated_at: now]
+ )
+ else
+ {0, nil}
+ end
+ end
+
+ if count > 0 do
+ Events.broadcast_categories_bulk_parent_changed(ids_to_update, parent_uuid)
+ end
+
+ count
+ end
+ end
+
+ @doc """
+ Bulk delete categories.
+ Returns count of deleted categories. Nullifies category references on orphaned products.
+ """
+ def bulk_delete_categories(ids) when is_list(ids) do
+ uuid_ids? = ids_are_uuids?(ids)
+
+ # Nullify category references on products to prevent orphans
+ orphan_query =
+ if uuid_ids? do
+ Product |> where([p], p.category_uuid in ^ids)
+ else
+ Product |> where([p], p.category_id in ^ids)
+ end
+
+ repo().update_all(orphan_query,
+ set: [category_id: nil, category_uuid: nil, updated_at: DateTime.utc_now()]
+ )
+
+ # Delete categories
+ category_query =
+ if uuid_ids? do
+ Category |> where([c], c.uuid in ^ids)
+ else
+ Category |> where([c], c.id in ^ids)
+ end
+
+ {count, _} = repo().delete_all(category_query)
+
+ if count > 0 do
+ Events.broadcast_categories_bulk_deleted(ids)
+ end
+
+ count
+ end
+
@doc """
Returns categories as options for select input.
Returns list of {localized_name, id} tuples.
@@ -1011,7 +1137,6 @@ defmodule PhoenixKit.Modules.Shop do
defp category_product_options_query(category_id) when is_integer(category_id) do
from(p in Product,
where: p.category_id == ^category_id,
- where: p.status == "active",
where:
not is_nil(p.featured_image_id) or
(not is_nil(p.featured_image) and p.featured_image != ""),
@@ -1024,7 +1149,6 @@ defmodule PhoenixKit.Modules.Shop do
if match?({:ok, _}, Ecto.UUID.cast(category_id)) do
from(p in Product,
where: p.category_uuid == ^category_id,
- where: p.status == "active",
where:
not is_nil(p.featured_image_id) or
(not is_nil(p.featured_image) and p.featured_image != ""),
@@ -2378,7 +2502,8 @@ defmodule PhoenixKit.Modules.Shop do
q,
[p],
fragment(
- "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(metadata->'_option_values'->?, '[]'::jsonb)) elem WHERE elem = ANY(?))",
+ "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(?->'_option_values'->?, '[]'::jsonb)) elem WHERE elem = ANY(?))",
+ p.metadata,
^key,
^values
)
@@ -2413,6 +2538,7 @@ defmodule PhoenixKit.Modules.Shop do
defp apply_category_filters(query, opts) do
query
|> filter_by_parent(Keyword.get(opts, :parent_id, :skip))
+ |> filter_by_parent_uuid(Keyword.get(opts, :parent_uuid, :skip))
|> filter_by_category_status(Keyword.get(opts, :status, :skip))
|> filter_by_category_search(Keyword.get(opts, :search))
end
@@ -2421,7 +2547,12 @@ defmodule PhoenixKit.Modules.Shop do
defp filter_by_parent(query, nil), do: where(query, [c], is_nil(c.parent_id))
defp filter_by_parent(query, id), do: where(query, [c], c.parent_id == ^id)
+ defp filter_by_parent_uuid(query, :skip), do: query
+ defp filter_by_parent_uuid(query, nil), do: where(query, [c], is_nil(c.parent_uuid))
+ defp filter_by_parent_uuid(query, uuid), do: where(query, [c], c.parent_uuid == ^uuid)
+
defp filter_by_category_status(query, :skip), do: query
+ defp filter_by_category_status(query, nil), do: query
defp filter_by_category_status(query, status) when is_binary(status) do
where(query, [c], c.status == ^status)
diff --git a/lib/modules/shop/web/catalog_category.ex b/lib/modules/shop/web/catalog_category.ex
index f4243733..de21c428 100644
--- a/lib/modules/shop/web/catalog_category.ex
+++ b/lib/modules/shop/web/catalog_category.ex
@@ -104,6 +104,8 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do
:category_icon_mode,
Settings.get_setting_cached("shop_category_icon_mode", "none")
)
+ |> assign(:admin_edit_url, Routes.path("/admin/shop/categories/#{category.uuid}/edit"))
+ |> assign(:admin_edit_label, "Edit Category")
{:ok, socket}
end
diff --git a/lib/modules/shop/web/catalog_product.ex b/lib/modules/shop/web/catalog_product.ex
index 2bcb533f..149ca01f 100644
--- a/lib/modules/shop/web/catalog_product.ex
+++ b/lib/modules/shop/web/catalog_product.ex
@@ -133,6 +133,8 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do
:category_icon_mode,
Settings.get_setting_cached("shop_category_icon_mode", "none")
)
+ |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.uuid}/edit"))
+ |> assign(:admin_edit_label, "Edit Product")
{:ok, socket}
end
@@ -262,6 +264,8 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do
:category_icon_mode,
Settings.get_setting_cached("shop_category_icon_mode", "none")
)
+ |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.id}"))
+ |> assign(:admin_edit_label, "Edit Product")
{:ok, socket}
end
diff --git a/lib/modules/shop/web/categories.ex b/lib/modules/shop/web/categories.ex
index 70c525a0..bb1fa811 100644
--- a/lib/modules/shop/web/categories.ex
+++ b/lib/modules/shop/web/categories.ex
@@ -1,6 +1,9 @@
defmodule PhoenixKit.Modules.Shop.Web.Categories do
@moduledoc """
Categories list LiveView for Shop module.
+
+ Provides search, filtering, pagination, and bulk operations
+ for category management.
"""
use PhoenixKitWeb, :live_view
@@ -9,26 +12,100 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
alias PhoenixKit.Modules.Shop.Category
alias PhoenixKit.Modules.Shop.Events
alias PhoenixKit.Modules.Shop.Translations
+ alias PhoenixKit.Users.Auth.Scope
alias PhoenixKit.Utils.Routes
+ @per_page 25
+
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Events.subscribe_categories()
end
- categories = Shop.list_categories(preload: [:parent, :featured_product])
current_language = Translations.default_language()
+ all_categories = Shop.list_categories(preload: [:parent])
+
+ {categories, total} =
+ Shop.list_categories_with_count(
+ per_page: @per_page,
+ preload: [:parent, :featured_product]
+ )
+
+ product_counts = Shop.product_counts_by_category()
socket =
socket
|> assign(:page_title, "Categories")
|> assign(:categories, categories)
+ |> assign(:total, total)
+ |> assign(:page, 1)
+ |> assign(:per_page, @per_page)
+ |> assign(:search, "")
+ |> assign(:status_filter, nil)
+ |> assign(:parent_filter, nil)
+ |> assign(:all_categories, all_categories)
|> assign(:current_language, current_language)
+ |> assign(:selected_ids, MapSet.new())
+ |> assign(:show_bulk_modal, nil)
+ |> assign(:product_counts, product_counts)
{:ok, socket}
end
+ # ============================================
+ # EVENT HANDLERS
+ # ============================================
+
+ @impl true
+ def handle_event("search", %{"search" => search}, socket) do
+ socket =
+ socket
+ |> assign(:search, search)
+ |> assign(:page, 1)
+ |> load_categories()
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("filter_status", %{"status" => status}, socket) do
+ status = if status == "", do: nil, else: status
+
+ socket =
+ socket
+ |> assign(:status_filter, status)
+ |> assign(:page, 1)
+ |> load_categories()
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("filter_parent", %{"parent" => parent}, socket) do
+ parent = if parent == "", do: nil, else: parent
+
+ socket =
+ socket
+ |> assign(:parent_filter, parent)
+ |> assign(:page, 1)
+ |> load_categories()
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("change_page", %{"page" => page}, socket) do
+ page = String.to_integer(page)
+
+ socket =
+ socket
+ |> assign(:page, page)
+ |> load_categories()
+
+ {:noreply, socket}
+ end
+
@impl true
def handle_event("delete", %{"uuid" => uuid}, socket) do
category = Shop.get_category!(uuid)
@@ -37,7 +114,7 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
{:ok, _} ->
{:noreply,
socket
- |> reload_categories()
+ |> load_categories()
|> put_flash(:info, "Category deleted")}
{:error, _} ->
@@ -45,29 +122,194 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
end
end
- # PubSub event handlers
+ @impl true
+ def handle_event("noop", _params, socket) do
+ {:noreply, socket}
+ end
+
+ # Bulk selection events
+
+ @impl true
+ def handle_event("toggle_select", %{"uuid" => uuid}, socket) do
+ selected = socket.assigns.selected_ids
+
+ selected =
+ if MapSet.member?(selected, uuid) do
+ MapSet.delete(selected, uuid)
+ else
+ MapSet.put(selected, uuid)
+ end
+
+ {:noreply, assign(socket, :selected_ids, selected)}
+ end
+
+ @impl true
+ def handle_event("select_all", _params, socket) do
+ all_uuids = Enum.map(socket.assigns.categories, & &1.uuid) |> MapSet.new()
+ current = socket.assigns.selected_ids
+
+ selected =
+ if MapSet.subset?(all_uuids, current) do
+ MapSet.difference(current, all_uuids)
+ else
+ MapSet.union(current, all_uuids)
+ end
+
+ {:noreply, assign(socket, :selected_ids, selected)}
+ end
+
+ @impl true
+ def handle_event("clear_selection", _params, socket) do
+ {:noreply, assign(socket, :selected_ids, MapSet.new())}
+ end
+
+ # Bulk action modals
+
+ @impl true
+ def handle_event("show_bulk_modal", %{"action" => action}, socket) do
+ {:noreply, assign(socket, :show_bulk_modal, action)}
+ end
+
+ @impl true
+ def handle_event("close_bulk_modal", _params, socket) do
+ {:noreply, assign(socket, :show_bulk_modal, nil)}
+ end
+
+ # Bulk actions (require admin role)
+
+ @impl true
+ def handle_event("bulk_change_status", %{"status" => status}, socket) do
+ if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
+ ids = MapSet.to_list(socket.assigns.selected_ids)
+ count = Shop.bulk_update_category_status(ids, status)
+
+ {:noreply,
+ socket
+ |> load_categories()
+ |> assign(:selected_ids, MapSet.new())
+ |> assign(:show_bulk_modal, nil)
+ |> put_flash(:info, "#{count} categories updated to #{status}")}
+ else
+ {:noreply, put_flash(socket, :error, "Not authorized")}
+ end
+ end
+
+ @impl true
+ def handle_event("bulk_change_parent", %{"parent_uuid" => parent_uuid}, socket) do
+ if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
+ ids = MapSet.to_list(socket.assigns.selected_ids)
+ parent_uuid = if parent_uuid == "", do: nil, else: parent_uuid
+ count = Shop.bulk_update_category_parent(ids, parent_uuid)
+
+ {:noreply,
+ socket
+ |> load_categories()
+ |> assign(:selected_ids, MapSet.new())
+ |> assign(:show_bulk_modal, nil)
+ |> put_flash(:info, "#{count} categories updated")}
+ else
+ {:noreply, put_flash(socket, :error, "Not authorized")}
+ end
+ end
+
+ @impl true
+ def handle_event("bulk_delete", _params, socket) do
+ if Scope.admin?(socket.assigns.phoenix_kit_current_scope) do
+ ids = MapSet.to_list(socket.assigns.selected_ids)
+ count = Shop.bulk_delete_categories(ids)
+
+ {:noreply,
+ socket
+ |> load_categories()
+ |> assign(:selected_ids, MapSet.new())
+ |> assign(:show_bulk_modal, nil)
+ |> put_flash(:info, "#{count} categories deleted")}
+ else
+ {:noreply, put_flash(socket, :error, "Not authorized")}
+ end
+ end
+
+ # ============================================
+ # PUBSUB HANDLERS
+ # ============================================
+
@impl true
def handle_info({:category_created, _category}, socket) do
- {:noreply, reload_categories(socket)}
+ {:noreply, load_categories(socket)}
end
@impl true
def handle_info({:category_updated, _category}, socket) do
- {:noreply, reload_categories(socket)}
+ {:noreply, load_categories(socket)}
end
@impl true
def handle_info({:category_deleted, _category_id}, socket) do
- {:noreply, reload_categories(socket)}
+ {:noreply, load_categories(socket)}
end
- defp reload_categories(socket) do
- categories = Shop.list_categories(preload: [:parent, :featured_product])
+ @impl true
+ def handle_info({:categories_bulk_status_changed, _ids, _status}, socket) do
+ {:noreply, load_categories(socket)}
+ end
+
+ @impl true
+ def handle_info({:categories_bulk_parent_changed, _ids, _parent_uuid}, socket) do
+ {:noreply, load_categories(socket)}
+ end
+
+ @impl true
+ def handle_info({:categories_bulk_deleted, _ids}, socket) do
+ {:noreply, load_categories(socket)}
+ end
+
+ # ============================================
+ # PRIVATE HELPERS
+ # ============================================
+
+ defp load_categories(socket) do
+ parent_uuid_opt =
+ case socket.assigns.parent_filter do
+ nil -> :skip
+ "root" -> nil
+ uuid -> uuid
+ end
+
+ opts = [
+ page: socket.assigns.page,
+ per_page: @per_page,
+ search: socket.assigns.search,
+ status: socket.assigns.status_filter,
+ parent_uuid: parent_uuid_opt,
+ preload: [:parent, :featured_product]
+ ]
+
+ {categories, total} = Shop.list_categories_with_count(opts)
+ all_categories = Shop.list_categories(preload: [:parent])
+
+ product_counts = Shop.product_counts_by_category()
socket
|> assign(:categories, categories)
+ |> assign(:total, total)
+ |> assign(:all_categories, all_categories)
+ |> assign(:product_counts, product_counts)
+ end
+
+ defp all_selected?(categories, selected_ids) do
+ categories != [] and
+ Enum.all?(categories, fn c -> MapSet.member?(selected_ids, c.uuid) end)
end
+ defp status_badge_class("active"), do: "badge badge-success"
+ defp status_badge_class("unlisted"), do: "badge badge-warning"
+ defp status_badge_class("hidden"), do: "badge badge-error"
+ defp status_badge_class(_), do: "badge badge-success"
+
+ # ============================================
+ # RENDER
+ # ============================================
+
@impl true
def render(assigns) do
~H"""
@@ -78,7 +320,7 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
current_locale={@current_locale}
page_title={@page_title}
>
-
+
<%!-- Header --%>
<%!-- Controls Bar --%>
-
- <%!-- Actions --%>
-
- <.link navigate={Routes.path("/admin/shop/categories/new")} class="btn btn-primary">
+
+ <%!-- Search --%>
+
+ Search
+
+
+
+ <%!-- Status Filter --%>
+
+ Status
+
+
+
+ <%!-- Parent Filter --%>
+
+ Parent
+
+
+
+ <%!-- Add Button --%>
+
+
+ <.link
+ navigate={Routes.path("/admin/shop/categories/new")}
+ class="btn btn-primary w-full"
+ >
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
+ <%!-- Bulk Actions Bar --%>
+ <%= if MapSet.size(@selected_ids) > 0 do %>
+
+
+
+
+ {MapSet.size(@selected_ids)} selected
+
+
+ Clear selection
+
+
+
+
+ <.icon name="hero-arrow-path" class="w-4 h-4 mr-1" /> Change Status
+
+
+ <.icon name="hero-folder" class="w-4 h-4 mr-1" /> Change Parent
+
+
+ <.icon name="hero-trash" class="w-4 h-4 mr-1" /> Delete
+
+
+
+
+ <% end %>
+
<%!-- Categories Table --%>
+
+
+
+
+
Name
Slug
Parent
Status
Position
+ Products
Actions
<%= if Enum.empty?(@categories) do %>
-
+
<.icon name="hero-folder" class="w-12 h-12 mx-auto mb-3 opacity-50" />
- No categories yet
+ No categories found
Create your first category to organize products
@@ -134,7 +476,24 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
<%= for category <- @categories do %>
<% cat_name = Translations.get(category, :name, @current_language) %>
<% cat_slug = Translations.get(category, :slug, @current_language) %>
-
+
+
+
+
+
+
@@ -156,7 +515,7 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
{Translations.get(category.parent, :name, @current_language)}
<% else %>
- —
+ —
<% end %>
@@ -165,7 +524,12 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
{category.position}
-
+
+
+ {Map.get(@product_counts, category.id, 0)}
+
+
+
<.link
navigate={Routes.path("/admin/shop/categories/#{category.uuid}/edit")}
@@ -189,14 +553,129 @@ defmodule PhoenixKit.Modules.Shop.Web.Categories do
+
+ <%!-- Pagination --%>
+ <%= if @total > @per_page do %>
+
+
+
+ <%= for page <- 1..ceil(@total / @per_page) do %>
+
+ {page}
+
+ <% end %>
+
+
+
+ <% end %>
+
+ <%!-- Bulk Status Change Modal --%>
+ <%= if @show_bulk_modal == "status" do %>
+
+
+
Change Status
+
+ Update status for {MapSet.size(@selected_ids)} selected categories
+
+
+
+ <.icon name="hero-check-circle" class="w-5 h-5 mr-2" /> Set Active
+
+
+ <.icon name="hero-eye-slash" class="w-5 h-5 mr-2" /> Set Unlisted
+
+
+ <.icon name="hero-x-circle" class="w-5 h-5 mr-2" /> Set Hidden
+
+
+
+ Cancel
+
+
+
+
+ <% end %>
+
+ <%!-- Bulk Parent Change Modal --%>
+ <%= if @show_bulk_modal == "parent" do %>
+
+
+
Change Parent
+
+ Set parent for {MapSet.size(@selected_ids)} selected categories
+
+
+
+ <.icon name="hero-x-mark" class="w-5 h-5 mr-2" /> Make Root (No Parent)
+
+ <%= for category <- @all_categories,
+ !MapSet.member?(@selected_ids, category.uuid) do %>
+
+ <.icon name="hero-folder" class="w-5 h-5 mr-2" /> {Translations.get(
+ category,
+ :name,
+ @current_language
+ )}
+
+ <% end %>
+
+
+ Cancel
+
+
+
+
+ <% end %>
+
+ <%!-- Bulk Delete Confirmation Modal --%>
+ <%= if @show_bulk_modal == "delete" do %>
+
+
+
Delete Categories
+
+ Are you sure you want to delete {MapSet.size(@selected_ids)} categories?
+ This action cannot be undone.
+
+
+ Cancel
+
+ <.icon name="hero-trash" class="w-4 h-4 mr-2" /> Delete Categories
+
+
+
+
+
+ <% end %>
"""
end
-
- defp status_badge_class("active"), do: "badge badge-success"
- defp status_badge_class("unlisted"), do: "badge badge-warning"
- defp status_badge_class("hidden"), do: "badge badge-error"
- defp status_badge_class(_), do: "badge badge-success"
end
diff --git a/lib/modules/shop/web/category_form.ex b/lib/modules/shop/web/category_form.ex
index 2dab0657..299e900b 100644
--- a/lib/modules/shop/web/category_form.ex
+++ b/lib/modules/shop/web/category_form.ex
@@ -570,7 +570,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CategoryForm do
<%!-- Featured Product (fallback image source) --%>
- <%= if @live_action == :edit and @product_options != [] do %>
+ <%= if @live_action == :edit do %>
diff --git a/lib/phoenix_kit_web/components/user_dashboard_nav.ex b/lib/phoenix_kit_web/components/user_dashboard_nav.ex
index 9a9b5122..f4bf6d8e 100644
--- a/lib/phoenix_kit_web/components/user_dashboard_nav.ex
+++ b/lib/phoenix_kit_web/components/user_dashboard_nav.ex
@@ -18,6 +18,8 @@ defmodule PhoenixKitWeb.Components.UserDashboardNav do
attr(:scope, :any, default: nil)
attr(:current_path, :string, default: "")
attr(:current_locale, :string, default: "en")
+ attr(:admin_edit_url, :string, default: nil)
+ attr(:admin_edit_label, :string, default: nil)
def user_dropdown(assigns) do
user = Scope.user(assigns.scope)
@@ -65,6 +67,17 @@ defmodule PhoenixKitWeb.Components.UserDashboardNav do
Admin Panel
+ <%= if @admin_edit_url do %>
+
+
+ <.icon name="hero-pencil-square" class="w-4 h-4" />
+ {@admin_edit_label || "Edit"}
+
+
+ <% end %>
<% end %>
diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex
index ba69043e..cae6e355 100644
--- a/lib/phoenix_kit_web/live/modules.html.heex
+++ b/lib/phoenix_kit_web/live/modules.html.heex
@@ -166,7 +166,7 @@
<.icon name="hero-envelope" class="w-4 h-4 mr-1" /> Emails
<.link
- navigate={PhoenixKit.Utils.Routes.path("/admin/modules/emails/templates")}
+ navigate={PhoenixKit.Utils.Routes.path("/admin/emails/templates")}
class="btn btn-outline btn-sm flex-1"
>
<.icon name="hero-document-text" class="w-4 h-4 mr-1" /> Templates
diff --git a/lib/phoenix_kit_web/routes/emails.ex b/lib/phoenix_kit_web/routes/emails.ex
index aca1c807..99f4621a 100644
--- a/lib/phoenix_kit_web/routes/emails.ex
+++ b/lib/phoenix_kit_web/routes/emails.ex
@@ -60,15 +60,15 @@ defmodule PhoenixKitWeb.Routes.EmailsRoutes do
live "/admin/emails/blocklist", PhoenixKit.Modules.Emails.Web.Blocklist, :index,
as: :emails_blocklist
- live "/admin/modules/emails/templates", PhoenixKit.Modules.Emails.Web.Templates, :index,
+ live "/admin/emails/templates", PhoenixKit.Modules.Emails.Web.Templates, :index,
as: :emails_templates
- live "/admin/modules/emails/templates/new",
+ live "/admin/emails/templates/new",
PhoenixKit.Modules.Emails.Web.TemplateEditor,
:new,
as: :emails_template_new
- live "/admin/modules/emails/templates/:id/edit",
+ live "/admin/emails/templates/:id/edit",
PhoenixKit.Modules.Emails.Web.TemplateEditor,
:edit,
as: :emails_template_edit
diff --git a/priv/static/images/mim/cards/instrument-shafts.png b/priv/static/images/mim/cards/instrument-shafts.png
new file mode 100644
index 00000000..fe64ea3d
Binary files /dev/null and b/priv/static/images/mim/cards/instrument-shafts.png differ
diff --git a/priv/static/images/mim/cards/instrument-tips.png b/priv/static/images/mim/cards/instrument-tips.png
new file mode 100644
index 00000000..537dea90
Binary files /dev/null and b/priv/static/images/mim/cards/instrument-tips.png differ
diff --git a/priv/static/images/mim/cards/small-components.png b/priv/static/images/mim/cards/small-components.png
new file mode 100644
index 00000000..f753611a
Binary files /dev/null and b/priv/static/images/mim/cards/small-components.png differ
diff --git a/priv/static/images/mim/cards/surgical-clamp.png b/priv/static/images/mim/cards/surgical-clamp.png
new file mode 100644
index 00000000..84f45644
Binary files /dev/null and b/priv/static/images/mim/cards/surgical-clamp.png differ
diff --git a/priv/static/images/mim/cards/surgical-handles.png b/priv/static/images/mim/cards/surgical-handles.png
new file mode 100644
index 00000000..57e715ad
Binary files /dev/null and b/priv/static/images/mim/cards/surgical-handles.png differ
diff --git a/priv/static/images/mim/instrument-shafts-nobg.png b/priv/static/images/mim/instrument-shafts-nobg.png
new file mode 100644
index 00000000..acfc03ae
Binary files /dev/null and b/priv/static/images/mim/instrument-shafts-nobg.png differ
diff --git a/priv/static/images/mim/instrument-tips-nobg.png b/priv/static/images/mim/instrument-tips-nobg.png
new file mode 100644
index 00000000..fffc24ec
Binary files /dev/null and b/priv/static/images/mim/instrument-tips-nobg.png differ
diff --git a/priv/static/images/mim/mim-all-instruments.png b/priv/static/images/mim/mim-all-instruments.png
new file mode 100644
index 00000000..b9187732
Binary files /dev/null and b/priv/static/images/mim/mim-all-instruments.png differ
diff --git a/priv/static/images/mim/small-components-nobg.png b/priv/static/images/mim/small-components-nobg.png
new file mode 100644
index 00000000..1be1ae97
Binary files /dev/null and b/priv/static/images/mim/small-components-nobg.png differ
diff --git a/priv/static/images/mim/surgical-clamp-nobg.png b/priv/static/images/mim/surgical-clamp-nobg.png
new file mode 100644
index 00000000..af85bc9f
Binary files /dev/null and b/priv/static/images/mim/surgical-clamp-nobg.png differ
diff --git a/priv/static/images/mim/surgical-handles-nobg.png b/priv/static/images/mim/surgical-handles-nobg.png
new file mode 100644
index 00000000..66bc295d
Binary files /dev/null and b/priv/static/images/mim/surgical-handles-nobg.png differ