diff --git a/lib/simpleshop_theme/products.ex b/lib/simpleshop_theme/products.ex
index e4c208d..12c555e 100644
--- a/lib/simpleshop_theme/products.ex
+++ b/lib/simpleshop_theme/products.ex
@@ -225,6 +225,7 @@ defmodule SimpleshopTheme.Products do
defp maybe_filter_on_sale(query, _), do: query
defp maybe_filter_in_stock(query, true), do: where(query, [p], p.in_stock == true)
+ defp maybe_filter_in_stock(query, false), do: where(query, [p], p.in_stock == false)
defp maybe_filter_in_stock(query, _), do: query
defp apply_sort(query, "price_asc"), do: order_by(query, [p], asc: p.cheapest_price)
@@ -264,6 +265,42 @@ defmodule SimpleshopTheme.Products do
|> Repo.all()
end
+ @doc """
+ Returns products for the admin list page with sorting, stock filtering,
+ and full preloads for display.
+
+ ## Options
+
+ * `:visible` - filter by visibility (boolean)
+ * `:status` - filter by status (string)
+ * `:category` - filter by category (string)
+ * `:provider_connection_id` - filter by provider connection
+ * `:in_stock` - filter by stock status (boolean)
+ * `:sort` - sort order (string)
+
+ """
+ def list_products_admin(opts \\ []) do
+ Product
+ |> apply_product_filters(opts)
+ |> maybe_filter_in_stock(opts[:in_stock])
+ |> apply_sort(opts[:sort])
+ |> preload([:provider_connection, images: :image, variants: []])
+ |> Repo.all()
+ end
+
+ @doc """
+ Returns distinct category names from all products (including hidden/draft).
+ """
+ def list_all_categories do
+ from(p in Product,
+ where: not is_nil(p.category),
+ select: p.category,
+ distinct: true,
+ order_by: p.category
+ )
+ |> Repo.all()
+ end
+
@doc """
Gets a single product by ID.
"""
@@ -310,6 +347,22 @@ defmodule SimpleshopTheme.Products do
|> Repo.update()
end
+ @doc """
+ Updates storefront-only fields (visibility and category).
+ """
+ def update_storefront(%Product{} = product, attrs) do
+ product
+ |> Product.storefront_changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Toggles a product's visibility.
+ """
+ def toggle_visibility(%Product{} = product) do
+ update_storefront(product, %{visible: !product.visible})
+ end
+
@doc """
Deletes a product.
"""
diff --git a/lib/simpleshop_theme/products/product.ex b/lib/simpleshop_theme/products/product.ex
index 36f2a19..21e8dc1 100644
--- a/lib/simpleshop_theme/products/product.ex
+++ b/lib/simpleshop_theme/products/product.ex
@@ -67,6 +67,14 @@ defmodule SimpleshopTheme.Products.Product do
|> unique_constraint([:provider_connection_id, :provider_product_id])
end
+ @doc """
+ Changeset for admin storefront controls (visibility and category only).
+ """
+ def storefront_changeset(product, attrs) do
+ product
+ |> cast(attrs, [:visible, :category])
+ end
+
@doc """
Changeset for recomputing denormalized fields from variants.
"""
diff --git a/lib/simpleshop_theme_web/components/layouts/admin.html.heex b/lib/simpleshop_theme_web/components/layouts/admin.html.heex
index a1811dd..0dc6ce4 100644
--- a/lib/simpleshop_theme_web/components/layouts/admin.html.heex
+++ b/lib/simpleshop_theme_web/components/layouts/admin.html.heex
@@ -61,6 +61,14 @@
<.icon name="hero-shopping-bag" class="size-5" /> Orders
+
+ <.link
+ navigate={~p"/admin/products"}
+ class={admin_nav_active?(@current_path, "/admin/products")}
+ >
+ <.icon name="hero-cube" class="size-5" /> Products
+
+
<.link
href={~p"/admin/theme"}
diff --git a/lib/simpleshop_theme_web/live/admin/dashboard.ex b/lib/simpleshop_theme_web/live/admin/dashboard.ex
index 71e4cdc..9e2559c 100644
--- a/lib/simpleshop_theme_web/live/admin/dashboard.ex
+++ b/lib/simpleshop_theme_web/live/admin/dashboard.ex
@@ -266,7 +266,7 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
label="Products"
value={@setup.product_count}
icon="hero-cube"
- href={~p"/admin/settings"}
+ href={~p"/admin/products"}
/>
diff --git a/lib/simpleshop_theme_web/live/admin/product_show.ex b/lib/simpleshop_theme_web/live/admin/product_show.ex
new file mode 100644
index 0000000..11663c4
--- /dev/null
+++ b/lib/simpleshop_theme_web/live/admin/product_show.ex
@@ -0,0 +1,342 @@
+defmodule SimpleshopThemeWeb.Admin.ProductShow do
+ use SimpleshopThemeWeb, :live_view
+
+ alias SimpleshopTheme.Products
+ alias SimpleshopTheme.Products.{Product, ProductImage, ProductVariant}
+ alias SimpleshopTheme.Cart
+
+ @impl true
+ def mount(%{"id" => id}, _session, socket) do
+ case Products.get_product(id, preload: [:provider_connection, images: :image, variants: []]) do
+ nil ->
+ socket =
+ socket
+ |> put_flash(:error, "Product not found")
+ |> push_navigate(to: ~p"/admin/products")
+
+ {:ok, socket}
+
+ product ->
+ form =
+ product
+ |> Product.storefront_changeset(%{})
+ |> to_form(as: "product")
+
+ socket =
+ socket
+ |> assign(:page_title, product.title)
+ |> assign(:product, product)
+ |> assign(:form, form)
+
+ {:ok, socket}
+ end
+ end
+
+ @impl true
+ def handle_event("validate_storefront", %{"product" => params}, socket) do
+ form =
+ socket.assigns.product
+ |> Product.storefront_changeset(params)
+ |> Map.put(:action, :validate)
+ |> to_form(as: "product")
+
+ {:noreply, assign(socket, :form, form)}
+ end
+
+ @impl true
+ def handle_event("save_storefront", %{"product" => params}, socket) do
+ case Products.update_storefront(socket.assigns.product, params) do
+ {:ok, updated} ->
+ product = %{
+ updated
+ | provider_connection: socket.assigns.product.provider_connection,
+ images: socket.assigns.product.images,
+ variants: socket.assigns.product.variants
+ }
+
+ form =
+ product
+ |> Product.storefront_changeset(%{})
+ |> to_form(as: "product")
+
+ socket =
+ socket
+ |> assign(:product, product)
+ |> assign(:form, form)
+ |> put_flash(:info, "Product updated")
+
+ {:noreply, socket}
+
+ {:error, changeset} ->
+ {:noreply, assign(socket, :form, to_form(changeset, as: "product"))}
+ end
+ end
+
+ @impl true
+ def handle_event("resync", _params, socket) do
+ product = socket.assigns.product
+
+ if product.provider_connection do
+ Products.enqueue_sync(product.provider_connection)
+
+ {:noreply, put_flash(socket, :info, "Sync queued for #{product.provider_connection.name}")}
+ else
+ {:noreply, put_flash(socket, :error, "No provider connection")}
+ end
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+ <.header>
+ <.link
+ navigate={~p"/admin/products"}
+ class="text-sm font-normal text-base-content/60 hover:underline"
+ >
+ ← Products
+
+
+ {@product.title}
+ <.visibility_badge visible={@product.visible} />
+ <.status_badge status={@product.status} />
+
+ <:actions>
+ <.link
+ :if={provider_edit_url(@product)}
+ href={provider_edit_url(@product)}
+ target="_blank"
+ rel="noopener"
+ class="btn btn-ghost btn-sm"
+ >
+ Edit on {provider_label(@product)}
+ <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
+
+ <.link navigate={~p"/products/#{@product.slug}"} class="btn btn-ghost btn-sm">
+ View on shop <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" />
+
+
+
+
+ <%!-- images + details --%>
+
+
+
+
+

+
+
+
No images
+
+
+
+
+
Details
+ <.list>
+ <:item :if={@product.provider_connection} title="Provider">
+ {provider_label(@product)} via {@product.provider_connection.name}
+
+ <:item title="Category">{@product.category || "—"}
+ <:item title="Price">{Cart.format_price(@product.cheapest_price)}
+ <:item title="Variants">{length(@product.variants)}
+ <:item title="Images">{length(@product.images)}
+ <:item title="Created">{format_date(@product.inserted_at)}
+ <:item
+ :if={@product.provider_connection && @product.provider_connection.last_synced_at}
+ title="Last synced"
+ >
+ {format_date(@product.provider_connection.last_synced_at)}
+
+
+
+
+
+
+ <%!-- storefront controls --%>
+
+
+
Storefront controls
+ <.form
+ for={@form}
+ phx-submit="save_storefront"
+ phx-change="validate_storefront"
+ class="flex flex-wrap gap-4 items-end"
+ >
+
+
+ <.button type="submit" class="btn-sm btn-primary">Save
+
+
+
+
+ <%!-- variants --%>
+
+
+
Variants ({length(@product.variants)})
+ <.table id="variants" rows={@product.variants}>
+ <:col :let={variant} label="Options">{ProductVariant.options_title(variant)}
+ <:col :let={variant} label="SKU">{variant.sku || "—"}
+ <:col :let={variant} label="Price">{Cart.format_price(variant.price)}
+ <:col :let={variant} label="Cost">
+ {if variant.cost, do: Cart.format_price(variant.cost), else: "—"}
+
+ <:col :let={variant} label="Profit">
+ {if ProductVariant.profit(variant),
+ do: Cart.format_price(ProductVariant.profit(variant)),
+ else: "—"}
+
+ <:col :let={variant} label="Available">
+ <.icon
+ :if={variant.is_enabled && variant.is_available}
+ name="hero-check-circle-mini"
+ class="size-5 text-green-600"
+ />
+ <.icon
+ :if={!variant.is_enabled || !variant.is_available}
+ name="hero-x-circle-mini"
+ class="size-5 text-base-content/30"
+ />
+
+
+
+
+
+ <%!-- provider data --%>
+
+
+
Provider data
+ <.list>
+ <:item title="Provider">
+ {provider_label(@product)} via {@product.provider_connection.name}
+
+ <:item title="Provider product ID">{@product.provider_product_id}
+ <:item title="Status">{@product.status}
+ <:item title="Sync status">{@product.provider_connection.sync_status}
+
+
+
+
+
+
+ """
+ end
+
+ # ---------------------------------------------------------------------------
+ # Helpers
+ # ---------------------------------------------------------------------------
+
+ defp sorted_images(product) do
+ (product.images || []) |> Enum.sort_by(& &1.position)
+ end
+
+ defp visibility_badge(assigns) do
+ {bg, text, ring, label} =
+ if assigns.visible do
+ {"bg-green-50", "text-green-700", "ring-green-600/20", "visible"}
+ else
+ {"bg-base-200/50", "text-base-content/60", "ring-base-content/10", "hidden"}
+ end
+
+ assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
+
+ ~H"""
+
+ {@label}
+
+ """
+ end
+
+ defp status_badge(assigns) do
+ {bg, text, ring} =
+ case assigns.status do
+ "active" -> {"bg-green-50", "text-green-700", "ring-green-600/20"}
+ "draft" -> {"bg-amber-50", "text-amber-700", "ring-amber-600/20"}
+ "archived" -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
+ _ -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
+ end
+
+ assigns = assign(assigns, bg: bg, text: text, ring: ring)
+
+ ~H"""
+
+ {@status}
+
+ """
+ end
+
+ defp provider_label(%{provider_connection: %{provider_type: "printify"}}), do: "Printify"
+ defp provider_label(%{provider_connection: %{provider_type: "printful"}}), do: "Printful"
+ defp provider_label(%{provider_connection: %{provider_type: type}}), do: String.capitalize(type)
+ defp provider_label(_), do: nil
+
+ defp provider_edit_url(%{
+ provider_connection: %{provider_type: "printify", config: config},
+ provider_product_id: pid
+ }) do
+ shop_id = config["shop_id"]
+ if shop_id && pid, do: "https://printify.com/app/editor/#{shop_id}/#{pid}"
+ end
+
+ defp provider_edit_url(%{
+ provider_connection: %{provider_type: "printful"},
+ provider_product_id: pid
+ }) do
+ if pid, do: "https://www.printful.com/dashboard/sync/update?id=#{pid}"
+ end
+
+ defp provider_edit_url(_), do: nil
+
+ defp format_date(nil), do: "—"
+ defp format_date(datetime), do: Calendar.strftime(datetime, "%d %b %Y %H:%M")
+end
diff --git a/lib/simpleshop_theme_web/live/admin/products.ex b/lib/simpleshop_theme_web/live/admin/products.ex
new file mode 100644
index 0000000..99742f1
--- /dev/null
+++ b/lib/simpleshop_theme_web/live/admin/products.ex
@@ -0,0 +1,285 @@
+defmodule SimpleshopThemeWeb.Admin.Products do
+ use SimpleshopThemeWeb, :live_view
+
+ alias SimpleshopTheme.Products
+ alias SimpleshopTheme.Products.{Product, ProductImage}
+ alias SimpleshopTheme.Cart
+
+ @impl true
+ def mount(_params, _session, socket) do
+ connections = Products.list_provider_connections()
+ categories = Products.list_all_categories()
+ products = Products.list_products_admin()
+
+ socket =
+ socket
+ |> assign(:page_title, "Products")
+ |> assign(:connections, connections)
+ |> assign(:categories, categories)
+ |> assign(:product_count, length(products))
+ |> assign(:provider_filter, "all")
+ |> assign(:category_filter, "all")
+ |> assign(:visibility_filter, "all")
+ |> assign(:stock_filter, "all")
+ |> assign(:sort, "newest")
+ |> stream(:products, products)
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_event("filter", params, socket) do
+ provider_filter = params["provider"] || socket.assigns.provider_filter
+ category_filter = params["category"] || socket.assigns.category_filter
+ visibility_filter = params["visibility"] || socket.assigns.visibility_filter
+ stock_filter = params["stock"] || socket.assigns.stock_filter
+ sort = params["sort"] || socket.assigns.sort
+
+ opts =
+ []
+ |> maybe_add_filter(:provider_connection_id, provider_filter)
+ |> maybe_add_filter(:category, category_filter)
+ |> maybe_add_visibility(visibility_filter)
+ |> maybe_add_stock(stock_filter)
+ |> Keyword.put(:sort, sort)
+
+ products = Products.list_products_admin(opts)
+
+ socket =
+ socket
+ |> assign(:provider_filter, provider_filter)
+ |> assign(:category_filter, category_filter)
+ |> assign(:visibility_filter, visibility_filter)
+ |> assign(:stock_filter, stock_filter)
+ |> assign(:sort, sort)
+ |> assign(:product_count, length(products))
+ |> stream(:products, products, reset: true)
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_event("toggle_visibility", %{"id" => id}, socket) do
+ product =
+ Products.get_product(id, preload: [:provider_connection, images: :image, variants: []])
+
+ case Products.toggle_visibility(product) do
+ {:ok, updated} ->
+ updated = %{
+ updated
+ | provider_connection: product.provider_connection,
+ images: product.images,
+ variants: product.variants
+ }
+
+ {:noreply, stream_insert(socket, :products, updated)}
+
+ {:error, _changeset} ->
+ {:noreply, put_flash(socket, :error, "Could not update visibility")}
+ end
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+ <.header>
+ Products
+ <:subtitle>{@product_count} products
+
+
+
+
+ <.table
+ :if={@product_count > 0}
+ id="products"
+ rows={@streams.products}
+ row_item={fn {_id, product} -> product end}
+ row_click={fn {_id, product} -> JS.navigate(~p"/admin/products/#{product}") end}
+ >
+ <:col :let={product} label="">
+ <.product_thumbnail product={product} />
+
+ <:col :let={product} label="Product">
+
+ <.link navigate={~p"/admin/products/#{product}"} class="hover:underline">
+ {product.title}
+
+
+ <.provider_badge :if={product.provider_connection} connection={product.provider_connection} />
+
+ <:col :let={product} label="Category">
+ {product.category || "—"}
+
+ <:col :let={product} label="Price">
+ Sale
+ {Cart.format_price(product.cheapest_price)}
+
+ <:col :let={product} label="Stock">
+ <.stock_badge in_stock={product.in_stock} />
+
+ <:col :let={product} label="Variants">
+ {length(product.variants)}
+
+ <:col :let={product} label="Visible">
+
+
+
+
+
+ <.icon name="hero-cube" class="size-12 mx-auto mb-4" />
+
No products yet
+
+ <.link navigate={~p"/admin/providers"} class="link link-primary">
+ Connect a provider
+
+ to sync your products.
+
+
+ """
+ end
+
+ # ---------------------------------------------------------------------------
+ # Helper components
+ # ---------------------------------------------------------------------------
+
+ defp filter_select(assigns) do
+ ~H"""
+
+ """
+ end
+
+ defp product_thumbnail(assigns) do
+ image = Product.primary_image(assigns.product)
+
+ url =
+ if image do
+ ProductImage.display_url(image, 80)
+ end
+
+ alt = (image && image.alt) || assigns.product.title
+
+ assigns = assign(assigns, url: url, alt: alt)
+
+ ~H"""
+
+

+
+ <.icon name="hero-photo" class="size-5 text-base-content/30" />
+
+
+ """
+ end
+
+ defp provider_badge(assigns) do
+ label =
+ case assigns.connection.provider_type do
+ "printify" -> "Printify"
+ "printful" -> "Printful"
+ other -> String.capitalize(other)
+ end
+
+ assigns = assign(assigns, :label, label)
+
+ ~H"""
+
+ {@label}
+
+ """
+ end
+
+ defp stock_badge(assigns) do
+ {bg, text, ring, label} =
+ if assigns.in_stock do
+ {"bg-green-50", "text-green-700", "ring-green-600/20", "In stock"}
+ else
+ {"bg-red-50", "text-red-700", "ring-red-600/20", "Out of stock"}
+ end
+
+ assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
+
+ ~H"""
+
+ {@label}
+
+ """
+ end
+
+ # ---------------------------------------------------------------------------
+ # Filter helpers
+ # ---------------------------------------------------------------------------
+
+ defp maybe_add_filter(opts, _key, "all"), do: opts
+ defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value)
+
+ defp maybe_add_visibility(opts, "visible"), do: Keyword.put(opts, :visible, true)
+ defp maybe_add_visibility(opts, "hidden"), do: Keyword.put(opts, :visible, false)
+ defp maybe_add_visibility(opts, _), do: opts
+
+ defp maybe_add_stock(opts, "in_stock"), do: Keyword.put(opts, :in_stock, true)
+ defp maybe_add_stock(opts, "out_of_stock"), do: Keyword.put(opts, :in_stock, false)
+ defp maybe_add_stock(opts, _), do: opts
+end
diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex
index b80cb20..0ca0376 100644
--- a/lib/simpleshop_theme_web/router.ex
+++ b/lib/simpleshop_theme_web/router.ex
@@ -153,6 +153,8 @@ defmodule SimpleshopThemeWeb.Router do
live "/", Admin.Dashboard, :index
live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show
+ live "/products", Admin.Products, :index
+ live "/products/:id", Admin.ProductShow, :show
live "/providers", Admin.Providers.Index, :index
live "/providers/new", Admin.Providers.Form, :new
live "/providers/:id/edit", Admin.Providers.Form, :edit
diff --git a/test/simpleshop_theme_web/live/admin/dashboard_test.exs b/test/simpleshop_theme_web/live/admin/dashboard_test.exs
index 1e00d37..7f66264 100644
--- a/test/simpleshop_theme_web/live/admin/dashboard_test.exs
+++ b/test/simpleshop_theme_web/live/admin/dashboard_test.exs
@@ -97,7 +97,7 @@ defmodule SimpleshopThemeWeb.Admin.DashboardTest do
{:ok, view, _html} = live(conn, ~p"/admin")
assert has_element?(view, "a[href='/admin/orders']", "Orders")
- assert has_element?(view, "a[href='/admin/settings']", "Products")
+ assert has_element?(view, "a[href='/admin/products']", "Products")
end
test "shows zero state for orders", %{conn: conn} do
diff --git a/test/simpleshop_theme_web/live/admin/products_test.exs b/test/simpleshop_theme_web/live/admin/products_test.exs
new file mode 100644
index 0000000..f497fab
--- /dev/null
+++ b/test/simpleshop_theme_web/live/admin/products_test.exs
@@ -0,0 +1,190 @@
+defmodule SimpleshopThemeWeb.Admin.ProductsTest do
+ use SimpleshopThemeWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ import SimpleshopTheme.AccountsFixtures
+ import SimpleshopTheme.ProductsFixtures
+
+ setup do
+ user = user_fixture()
+ conn = provider_connection_fixture(%{provider_type: "printify", name: "Test Shop"})
+ product = complete_product_fixture(%{provider_connection: conn})
+
+ %{user: user, connection: conn, product: product}
+ end
+
+ describe "unauthenticated" do
+ test "product list redirects to login", %{conn: conn} do
+ {:error, redirect} = live(conn, ~p"/admin/products")
+ assert {:redirect, %{to: path}} = redirect
+ assert path == ~p"/users/log-in"
+ end
+
+ test "product detail redirects to login", %{conn: conn, product: product} do
+ {:error, redirect} = live(conn, ~p"/admin/products/#{product}")
+ assert {:redirect, %{to: path}} = redirect
+ assert path == ~p"/users/log-in"
+ end
+ end
+
+ describe "product list" do
+ setup %{conn: conn, user: user} do
+ %{conn: log_in_user(conn, user)}
+ end
+
+ test "renders product list", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products")
+
+ assert html =~ "Products"
+ assert html =~ product.title
+ end
+
+ test "shows provider badge", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products")
+
+ assert html =~ "Printify"
+ end
+
+ test "shows category", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products")
+
+ assert html =~ product.category
+ end
+
+ test "links to product detail", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products")
+
+ assert html =~ ~p"/admin/products/#{product}"
+ end
+
+ test "toggle visibility updates product", %{conn: conn, product: product} do
+ {:ok, view, _html} = live(conn, ~p"/admin/products")
+
+ assert product.visible
+
+ view
+ |> element("button[phx-value-id='#{product.id}']")
+ |> render_click()
+
+ updated = SimpleshopTheme.Products.get_product(product.id)
+ refute updated.visible
+ end
+
+ test "filters by visibility", %{conn: conn, product: product} do
+ SimpleshopTheme.Products.update_storefront(product, %{visible: false})
+
+ {:ok, view, _html} = live(conn, ~p"/admin/products")
+
+ html =
+ view
+ |> element("form")
+ |> render_change(%{"visibility" => "hidden"})
+
+ assert html =~ product.title
+
+ html =
+ view
+ |> element("form")
+ |> render_change(%{"visibility" => "visible"})
+
+ refute html =~ product.title
+ end
+
+ test "filters by category", %{conn: conn, product: product} do
+ {:ok, view, _html} = live(conn, ~p"/admin/products")
+
+ html =
+ view
+ |> element("form")
+ |> render_change(%{"category" => product.category})
+
+ assert html =~ product.title
+
+ html =
+ view
+ |> element("form")
+ |> render_change(%{"category" => "Nonexistent"})
+
+ refute html =~ product.title
+ end
+
+ test "sorts by name", %{conn: conn, product: product} do
+ {:ok, view, _html} = live(conn, ~p"/admin/products")
+
+ html =
+ view
+ |> element("form")
+ |> render_change(%{"sort" => "name_asc"})
+
+ assert html =~ product.title
+ end
+
+ test "shows empty state when no products", %{conn: conn, product: product} do
+ SimpleshopTheme.Products.delete_product(product)
+
+ {:ok, _view, html} = live(conn, ~p"/admin/products")
+
+ assert html =~ "No products yet"
+ assert html =~ "Connect a provider"
+ end
+ end
+
+ describe "product detail" do
+ setup %{conn: conn, user: user} do
+ %{conn: log_in_user(conn, user)}
+ end
+
+ test "renders product detail", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products/#{product}")
+
+ assert html =~ product.title
+ assert html =~ "Details"
+ assert html =~ "Storefront controls"
+ assert html =~ "Variants"
+ end
+
+ test "shows provider link", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products/#{product}")
+
+ assert html =~ "Edit on Printify"
+ end
+
+ test "shows view on shop link", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products/#{product}")
+
+ assert html =~ ~p"/products/#{product.slug}"
+ end
+
+ test "shows variant details", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products/#{product}")
+
+ assert html =~ "£25.00"
+ end
+
+ test "saves storefront changes", %{conn: conn, product: product} do
+ {:ok, view, _html} = live(conn, ~p"/admin/products/#{product}")
+
+ view
+ |> element("form")
+ |> render_submit(%{"product" => %{"visible" => "false", "category" => "New Category"}})
+
+ updated = SimpleshopTheme.Products.get_product(product.id)
+ refute updated.visible
+ assert updated.category == "New Category"
+ end
+
+ test "redirects on not found", %{conn: conn} do
+ {:error, {:live_redirect, %{to: path}}} =
+ live(conn, "/admin/products/00000000-0000-0000-0000-000000000000")
+
+ assert path == "/admin/products"
+ end
+
+ test "back link navigates to list", %{conn: conn, product: product} do
+ {:ok, _view, html} = live(conn, ~p"/admin/products/#{product}")
+
+ assert html =~ ~s(href="/admin/products")
+ assert html =~ "Products"
+ end
+ end
+end