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_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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
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 "/", 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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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