add admin products list and detail pages

Read-mostly admin views for synced products: filterable/sortable list
with inline visibility toggle, and detail page with images grid,
variants table, storefront controls form, and provider edit links.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-16 08:48:51 +00:00
parent ccc14aa5e1
commit 81e94d0d65
9 changed files with 890 additions and 2 deletions

View File

@ -225,6 +225,7 @@ defmodule SimpleshopTheme.Products do
defp maybe_filter_on_sale(query, _), do: query 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, 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 maybe_filter_in_stock(query, _), do: query
defp apply_sort(query, "price_asc"), do: order_by(query, [p], asc: p.cheapest_price) 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() |> Repo.all()
end 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 """ @doc """
Gets a single product by ID. Gets a single product by ID.
""" """
@ -310,6 +347,22 @@ defmodule SimpleshopTheme.Products do
|> Repo.update() |> Repo.update()
end 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 """ @doc """
Deletes a product. Deletes a product.
""" """

View File

@ -67,6 +67,14 @@ defmodule SimpleshopTheme.Products.Product do
|> unique_constraint([:provider_connection_id, :provider_product_id]) |> unique_constraint([:provider_connection_id, :provider_product_id])
end end
@doc """
Changeset for admin storefront controls (visibility and category only).
"""
def storefront_changeset(product, attrs) do
product
|> cast(attrs, [:visible, :category])
end
@doc """ @doc """
Changeset for recomputing denormalized fields from variants. Changeset for recomputing denormalized fields from variants.
""" """

View File

@ -61,6 +61,14 @@
<.icon name="hero-shopping-bag" class="size-5" /> Orders <.icon name="hero-shopping-bag" class="size-5" /> Orders
</.link> </.link>
</li> </li>
<li>
<.link
navigate={~p"/admin/products"}
class={admin_nav_active?(@current_path, "/admin/products")}
>
<.icon name="hero-cube" class="size-5" /> Products
</.link>
</li>
<li> <li>
<.link <.link
href={~p"/admin/theme"} href={~p"/admin/theme"}

View File

@ -266,7 +266,7 @@ defmodule SimpleshopThemeWeb.Admin.Dashboard do
label="Products" label="Products"
value={@setup.product_count} value={@setup.product_count}
icon="hero-cube" icon="hero-cube"
href={~p"/admin/settings"} href={~p"/admin/products"}
/> />
</div> </div>

View File

@ -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"
>
&larr; Products
</.link>
<div class="flex items-center gap-3 mt-1">
<span class="text-2xl font-bold">{@product.title}</span>
<.visibility_badge visible={@product.visible} />
<.status_badge status={@product.status} />
</div>
<: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>
<.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" />
</.link>
</:actions>
</.header>
<%!-- images + details --%>
<div class="grid gap-6 mt-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
<div
:for={image <- sorted_images(@product)}
class="aspect-square rounded bg-base-200 overflow-hidden"
>
<img
src={ProductImage.display_url(image, 400)}
alt={image.alt || @product.title}
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
</div>
<p :if={@product.images == []} class="text-base-content/40 text-sm">No images</p>
</div>
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body">
<h3 class="card-title text-base">Details</h3>
<.list>
<:item :if={@product.provider_connection} title="Provider">
{provider_label(@product)} via {@product.provider_connection.name}
</:item>
<:item title="Category">{@product.category || ""}</:item>
<:item title="Price">{Cart.format_price(@product.cheapest_price)}</:item>
<:item title="Variants">{length(@product.variants)}</:item>
<:item title="Images">{length(@product.images)}</:item>
<:item title="Created">{format_date(@product.inserted_at)}</:item>
<:item
:if={@product.provider_connection && @product.provider_connection.last_synced_at}
title="Last synced"
>
{format_date(@product.provider_connection.last_synced_at)}
</:item>
</.list>
</div>
</div>
</div>
<%!-- storefront controls --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
<div class="card-body">
<h3 class="card-title text-base">Storefront controls</h3>
<.form
for={@form}
phx-submit="save_storefront"
phx-change="validate_storefront"
class="flex flex-wrap gap-4 items-end"
>
<label class="form-control w-auto">
<span class="label-text text-xs mb-0.5">Visibility</span>
<select
name="product[visible]"
class="select select-bordered select-sm"
aria-label="Visibility"
>
<option
value="true"
selected={@form[:visible].value == true || @form[:visible].value == "true"}
>
Visible
</option>
<option
value="false"
selected={@form[:visible].value == false || @form[:visible].value == "false"}
>
Hidden
</option>
</select>
</label>
<label class="form-control w-auto flex-1 min-w-48">
<span class="label-text text-xs mb-0.5">Category</span>
<input
type="text"
name="product[category]"
value={@form[:category].value}
class="input input-bordered input-sm"
placeholder="e.g. Apparel"
/>
</label>
<.button type="submit" class="btn-sm btn-primary">Save</.button>
</.form>
</div>
</div>
<%!-- variants --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
<div class="card-body">
<h3 class="card-title text-base">Variants ({length(@product.variants)})</h3>
<.table id="variants" rows={@product.variants}>
<:col :let={variant} label="Options">{ProductVariant.options_title(variant)}</:col>
<:col :let={variant} label="SKU">{variant.sku || ""}</:col>
<:col :let={variant} label="Price">{Cart.format_price(variant.price)}</:col>
<:col :let={variant} label="Cost">
{if variant.cost, do: Cart.format_price(variant.cost), else: ""}
</:col>
<:col :let={variant} label="Profit">
{if ProductVariant.profit(variant),
do: Cart.format_price(ProductVariant.profit(variant)),
else: ""}
</:col>
<: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"
/>
</:col>
</.table>
</div>
</div>
<%!-- provider data --%>
<div
:if={@product.provider_connection}
class="card bg-base-100 shadow-sm border border-base-200 mt-6"
>
<div class="card-body">
<h3 class="card-title text-base">Provider data</h3>
<.list>
<:item title="Provider">
{provider_label(@product)} via {@product.provider_connection.name}
</:item>
<:item title="Provider product ID">{@product.provider_product_id}</:item>
<:item title="Status">{@product.status}</:item>
<:item title="Sync status">{@product.provider_connection.sync_status}</:item>
</.list>
<div class="mt-4">
<button phx-click="resync" class="btn btn-outline btn-sm">
<.icon name="hero-arrow-path" class="size-4" /> Re-sync
</button>
</div>
</div>
</div>
"""
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"""
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@label}
</span>
"""
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"""
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@status}
</span>
"""
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

View File

@ -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</:subtitle>
</.header>
<form phx-change="filter" class="flex gap-2 mt-6 mb-4 flex-wrap items-end">
<.filter_select
:if={length(@connections) > 1}
name="provider"
label="Provider"
value={@provider_filter}
options={[{"All providers", "all"}] ++ Enum.map(@connections, &{&1.name, &1.id})}
/>
<.filter_select
:if={@categories != []}
name="category"
label="Category"
value={@category_filter}
options={[{"All categories", "all"}] ++ Enum.map(@categories, &{&1, &1})}
/>
<.filter_select
name="visibility"
label="Visibility"
value={@visibility_filter}
options={[{"All", "all"}, {"Visible", "visible"}, {"Hidden", "hidden"}]}
/>
<.filter_select
name="stock"
label="Stock"
value={@stock_filter}
options={[{"All", "all"}, {"In stock", "in_stock"}, {"Out of stock", "out_of_stock"}]}
/>
<.filter_select
name="sort"
label="Sort"
value={@sort}
options={[
{"Newest", "newest"},
{"Name A\u2013Z", "name_asc"},
{"Name Z\u2013A", "name_desc"},
{"Price: low\u2013high", "price_asc"},
{"Price: high\u2013low", "price_desc"}
]}
/>
</form>
<.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>
<:col :let={product} label="Product">
<div class="font-medium">
<.link navigate={~p"/admin/products/#{product}"} class="hover:underline">
{product.title}
</.link>
</div>
<.provider_badge :if={product.provider_connection} connection={product.provider_connection} />
</:col>
<:col :let={product} label="Category">
{product.category || ""}
</:col>
<:col :let={product} label="Price">
<span :if={product.on_sale} class="text-red-600 text-xs font-medium mr-1">Sale</span>
{Cart.format_price(product.cheapest_price)}
</:col>
<:col :let={product} label="Stock">
<.stock_badge in_stock={product.in_stock} />
</:col>
<:col :let={product} label="Variants">
{length(product.variants)}
</:col>
<:col :let={product} label="Visible">
<button
phx-click="toggle_visibility"
phx-value-id={product.id}
aria-pressed={to_string(product.visible)}
aria-label={"Toggle visibility for #{product.title}"}
class={[
"swap swap-rotate btn btn-ghost btn-xs",
product.visible && "text-green-600",
!product.visible && "text-base-content/30"
]}
>
<.icon :if={product.visible} name="hero-eye" class="size-5" />
<.icon :if={!product.visible} name="hero-eye-slash" class="size-5" />
</button>
</:col>
</.table>
<div :if={@product_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-cube" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No products yet</p>
<p class="text-sm mt-1">
<.link navigate={~p"/admin/providers"} class="link link-primary">
Connect a provider
</.link>
to sync your products.
</p>
</div>
"""
end
# ---------------------------------------------------------------------------
# Helper components
# ---------------------------------------------------------------------------
defp filter_select(assigns) do
~H"""
<label class="form-control w-auto">
<span class="label-text text-xs mb-0.5">{@label}</span>
<select name={@name} class="select select-bordered select-sm" aria-label={@label}>
<option :for={{label, value} <- @options} value={value} selected={value == @value}>
{label}
</option>
</select>
</label>
"""
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"""
<div class="w-10 h-10 rounded bg-base-200 overflow-hidden flex-shrink-0">
<img :if={@url} src={@url} alt={@alt} class="w-full h-full object-cover" loading="lazy" />
<div :if={!@url} class="w-full h-full flex items-center justify-center">
<.icon name="hero-photo" class="size-5 text-base-content/30" />
</div>
</div>
"""
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"""
<span class="inline-flex items-center rounded-full bg-base-200 px-1.5 py-0.5 text-xs text-base-content/60 mt-0.5">
{@label}
</span>
"""
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"""
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@label}
</span>
"""
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

View File

@ -153,6 +153,8 @@ defmodule SimpleshopThemeWeb.Router do
live "/", Admin.Dashboard, :index live "/", Admin.Dashboard, :index
live "/orders", Admin.Orders, :index live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show 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", Admin.Providers.Index, :index
live "/providers/new", Admin.Providers.Form, :new live "/providers/new", Admin.Providers.Form, :new
live "/providers/:id/edit", Admin.Providers.Form, :edit live "/providers/:id/edit", Admin.Providers.Form, :edit

View File

@ -97,7 +97,7 @@ defmodule SimpleshopThemeWeb.Admin.DashboardTest do
{:ok, view, _html} = live(conn, ~p"/admin") {:ok, view, _html} = live(conn, ~p"/admin")
assert has_element?(view, "a[href='/admin/orders']", "Orders") 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 end
test "shows zero state for orders", %{conn: conn} do test "shows zero state for orders", %{conn: conn} do

View File

@ -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