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

    No images

    +
    + +
    +
    +

    Details

    + <.list> + <:item :if={@product.provider_connection} title="Provider"> + {provider_label(@product)} via {@product.provider_connection.name} + + <:item title="Category">{@product.category || "—"} + <:item title="Price">{Cart.format_price(@product.cheapest_price)} + <:item title="Variants">{length(@product.variants)} + <:item title="Images">{length(@product.images)} + <:item title="Created">{format_date(@product.inserted_at)} + <:item + :if={@product.provider_connection && @product.provider_connection.last_synced_at} + title="Last synced" + > + {format_date(@product.provider_connection.last_synced_at)} + + +
    +
    +
    + + <%!-- storefront controls --%> +
    +
    +

    Storefront controls

    + <.form + for={@form} + phx-submit="save_storefront" + phx-change="validate_storefront" + class="flex flex-wrap gap-4 items-end" + > + + + <.button type="submit" class="btn-sm btn-primary">Save + +
    +
    + + <%!-- variants --%> +
    +
    +

    Variants ({length(@product.variants)})

    + <.table id="variants" rows={@product.variants}> + <:col :let={variant} label="Options">{ProductVariant.options_title(variant)} + <:col :let={variant} label="SKU">{variant.sku || "—"} + <:col :let={variant} label="Price">{Cart.format_price(variant.price)} + <:col :let={variant} label="Cost"> + {if variant.cost, do: Cart.format_price(variant.cost), else: "—"} + + <:col :let={variant} label="Profit"> + {if ProductVariant.profit(variant), + do: Cart.format_price(ProductVariant.profit(variant)), + else: "—"} + + <:col :let={variant} label="Available"> + <.icon + :if={variant.is_enabled && variant.is_available} + name="hero-check-circle-mini" + class="size-5 text-green-600" + /> + <.icon + :if={!variant.is_enabled || !variant.is_available} + name="hero-x-circle-mini" + class="size-5 text-base-content/30" + /> + + +
    +
    + + <%!-- provider data --%> +
    +
    +

    Provider data

    + <.list> + <:item title="Provider"> + {provider_label(@product)} via {@product.provider_connection.name} + + <:item title="Provider product ID">{@product.provider_product_id} + <:item title="Status">{@product.status} + <:item title="Sync status">{@product.provider_connection.sync_status} + +
    + +
    +
    +
    + """ + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + defp sorted_images(product) do + (product.images || []) |> Enum.sort_by(& &1.position) + end + + defp visibility_badge(assigns) do + {bg, text, ring, label} = + if assigns.visible do + {"bg-green-50", "text-green-700", "ring-green-600/20", "visible"} + else + {"bg-base-200/50", "text-base-content/60", "ring-base-content/10", "hidden"} + end + + assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label) + + ~H""" + + {@label} + + """ + end + + defp status_badge(assigns) do + {bg, text, ring} = + case assigns.status do + "active" -> {"bg-green-50", "text-green-700", "ring-green-600/20"} + "draft" -> {"bg-amber-50", "text-amber-700", "ring-amber-600/20"} + "archived" -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"} + _ -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"} + end + + assigns = assign(assigns, bg: bg, text: text, ring: ring) + + ~H""" + + {@status} + + """ + end + + defp provider_label(%{provider_connection: %{provider_type: "printify"}}), do: "Printify" + defp provider_label(%{provider_connection: %{provider_type: "printful"}}), do: "Printful" + defp provider_label(%{provider_connection: %{provider_type: type}}), do: String.capitalize(type) + defp provider_label(_), do: nil + + defp provider_edit_url(%{ + provider_connection: %{provider_type: "printify", config: config}, + provider_product_id: pid + }) do + shop_id = config["shop_id"] + if shop_id && pid, do: "https://printify.com/app/editor/#{shop_id}/#{pid}" + end + + defp provider_edit_url(%{ + provider_connection: %{provider_type: "printful"}, + provider_product_id: pid + }) do + if pid, do: "https://www.printful.com/dashboard/sync/update?id=#{pid}" + end + + defp provider_edit_url(_), do: nil + + defp format_date(nil), do: "—" + defp format_date(datetime), do: Calendar.strftime(datetime, "%d %b %Y %H:%M") +end diff --git a/lib/simpleshop_theme_web/live/admin/products.ex b/lib/simpleshop_theme_web/live/admin/products.ex new file mode 100644 index 0000000..99742f1 --- /dev/null +++ b/lib/simpleshop_theme_web/live/admin/products.ex @@ -0,0 +1,285 @@ +defmodule SimpleshopThemeWeb.Admin.Products do + use SimpleshopThemeWeb, :live_view + + alias SimpleshopTheme.Products + alias SimpleshopTheme.Products.{Product, ProductImage} + alias SimpleshopTheme.Cart + + @impl true + def mount(_params, _session, socket) do + connections = Products.list_provider_connections() + categories = Products.list_all_categories() + products = Products.list_products_admin() + + socket = + socket + |> assign(:page_title, "Products") + |> assign(:connections, connections) + |> assign(:categories, categories) + |> assign(:product_count, length(products)) + |> assign(:provider_filter, "all") + |> assign(:category_filter, "all") + |> assign(:visibility_filter, "all") + |> assign(:stock_filter, "all") + |> assign(:sort, "newest") + |> stream(:products, products) + + {:ok, socket} + end + + @impl true + def handle_event("filter", params, socket) do + provider_filter = params["provider"] || socket.assigns.provider_filter + category_filter = params["category"] || socket.assigns.category_filter + visibility_filter = params["visibility"] || socket.assigns.visibility_filter + stock_filter = params["stock"] || socket.assigns.stock_filter + sort = params["sort"] || socket.assigns.sort + + opts = + [] + |> maybe_add_filter(:provider_connection_id, provider_filter) + |> maybe_add_filter(:category, category_filter) + |> maybe_add_visibility(visibility_filter) + |> maybe_add_stock(stock_filter) + |> Keyword.put(:sort, sort) + + products = Products.list_products_admin(opts) + + socket = + socket + |> assign(:provider_filter, provider_filter) + |> assign(:category_filter, category_filter) + |> assign(:visibility_filter, visibility_filter) + |> assign(:stock_filter, stock_filter) + |> assign(:sort, sort) + |> assign(:product_count, length(products)) + |> stream(:products, products, reset: true) + + {:noreply, socket} + end + + @impl true + def handle_event("toggle_visibility", %{"id" => id}, socket) do + product = + Products.get_product(id, preload: [:provider_connection, images: :image, variants: []]) + + case Products.toggle_visibility(product) do + {:ok, updated} -> + updated = %{ + updated + | provider_connection: product.provider_connection, + images: product.images, + variants: product.variants + } + + {:noreply, stream_insert(socket, :products, updated)} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Could not update visibility")} + end + end + + @impl true + def render(assigns) do + ~H""" + <.header> + Products + <:subtitle>{@product_count} products + + +
    + <.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"} + ]} + /> +
    + + <.table + :if={@product_count > 0} + id="products" + rows={@streams.products} + row_item={fn {_id, product} -> product end} + row_click={fn {_id, product} -> JS.navigate(~p"/admin/products/#{product}") end} + > + <:col :let={product} label=""> + <.product_thumbnail product={product} /> + + <:col :let={product} label="Product"> +
    + <.link navigate={~p"/admin/products/#{product}"} class="hover:underline"> + {product.title} + +
    + <.provider_badge :if={product.provider_connection} connection={product.provider_connection} /> + + <:col :let={product} label="Category"> + {product.category || "—"} + + <:col :let={product} label="Price"> + Sale + {Cart.format_price(product.cheapest_price)} + + <:col :let={product} label="Stock"> + <.stock_badge in_stock={product.in_stock} /> + + <:col :let={product} label="Variants"> + {length(product.variants)} + + <:col :let={product} label="Visible"> + + + + +
    + <.icon name="hero-cube" class="size-12 mx-auto mb-4" /> +

    No products yet

    +

    + <.link navigate={~p"/admin/providers"} class="link link-primary"> + Connect a provider + + to sync your products. +

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