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:
parent
ccc14aa5e1
commit
81e94d0d65
@ -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.
|
||||
"""
|
||||
|
||||
@ -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.
|
||||
"""
|
||||
|
||||
@ -61,6 +61,14 @@
|
||||
<.icon name="hero-shopping-bag" class="size-5" /> Orders
|
||||
</.link>
|
||||
</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>
|
||||
<.link
|
||||
href={~p"/admin/theme"}
|
||||
|
||||
@ -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"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
342
lib/simpleshop_theme_web/live/admin/product_show.ex
Normal file
342
lib/simpleshop_theme_web/live/admin/product_show.ex
Normal 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"
|
||||
>
|
||||
← 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
|
||||
285
lib/simpleshop_theme_web/live/admin/products.ex
Normal file
285
lib/simpleshop_theme_web/live/admin/products.ex
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
190
test/simpleshop_theme_web/live/admin/products_test.exs
Normal file
190
test/simpleshop_theme_web/live/admin/products_test.exs
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user