diff --git a/lib/simpleshop_theme/orders.ex b/lib/simpleshop_theme/orders.ex index e4fcbcc..855a335 100644 --- a/lib/simpleshop_theme/orders.ex +++ b/lib/simpleshop_theme/orders.ex @@ -10,6 +10,41 @@ defmodule SimpleshopTheme.Orders do alias SimpleshopTheme.Repo alias SimpleshopTheme.Orders.{Order, OrderItem} + @doc """ + Lists orders, optionally filtered by payment status. + + ## Options + + * `:status` - filter by payment_status ("paid", "pending", "failed", "refunded") + Pass nil or "all" to return all orders. + + Returns orders sorted by newest first, with items preloaded. + """ + def list_orders(opts \\ []) do + status = opts[:status] + + Order + |> maybe_filter_status(status) + |> order_by([o], desc: o.inserted_at) + |> preload(:items) + |> Repo.all() + end + + defp maybe_filter_status(query, nil), do: query + defp maybe_filter_status(query, "all"), do: query + defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status) + + @doc """ + Returns a map of payment_status => count for all orders. + """ + def count_orders_by_status do + Order + |> group_by(:payment_status) + |> select([o], {o.payment_status, count(o.id)}) + |> Repo.all() + |> Map.new() + end + @doc """ Creates an order with line items from hydrated cart data. diff --git a/lib/simpleshop_theme_web/components/layouts/root.html.heex b/lib/simpleshop_theme_web/components/layouts/root.html.heex index a8bdedf..e5040ed 100644 --- a/lib/simpleshop_theme_web/components/layouts/root.html.heex +++ b/lib/simpleshop_theme_web/components/layouts/root.html.heex @@ -45,6 +45,9 @@
  • <.link href={~p"/admin/theme"}>Theme
  • +
  • + <.link href={~p"/admin/orders"}>Orders +
  • <.link href={~p"/admin/settings"}>Credentials
  • diff --git a/lib/simpleshop_theme_web/live/admin_live/order_show.ex b/lib/simpleshop_theme_web/live/admin_live/order_show.ex new file mode 100644 index 0000000..e5174b0 --- /dev/null +++ b/lib/simpleshop_theme_web/live/admin_live/order_show.ex @@ -0,0 +1,175 @@ +defmodule SimpleshopThemeWeb.AdminLive.OrderShow do + use SimpleshopThemeWeb, :live_view + + alias SimpleshopTheme.Orders + alias SimpleshopTheme.Cart + + @impl true + def mount(%{"id" => id}, _session, socket) do + case Orders.get_order(id) do + nil -> + socket = + socket + |> put_flash(:error, "Order not found") + |> push_navigate(to: ~p"/admin/orders") + + {:ok, socket} + + order -> + socket = + socket + |> assign(:page_title, order.order_number) + |> assign(:order, order) + + {:ok, socket} + end + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + <.link + navigate={~p"/admin/orders"} + class="text-sm font-normal text-base-content/60 hover:underline" + > + ← Orders + +
    + {@order.order_number} + <.status_badge status={@order.payment_status} /> +
    + + +
    + <%!-- order info --%> +
    +
    +

    Order details

    + <.list> + <:item title="Date">{format_date(@order.inserted_at)} + <:item title="Customer">{@order.customer_email || "—"} + <:item title="Payment status"> + <.status_badge status={@order.payment_status} /> + + <:item :if={@order.stripe_payment_intent_id} title="Stripe payment"> + {@order.stripe_payment_intent_id} + + <:item title="Currency">{String.upcase(@order.currency)} + +
    +
    + + <%!-- shipping address --%> +
    +
    +

    Shipping address

    + <%= if @order.shipping_address != %{} do %> + <.list> + <:item :if={@order.shipping_address["name"]} title="Name"> + {@order.shipping_address["name"]} + + <:item :if={@order.shipping_address["line1"]} title="Address"> + {@order.shipping_address["line1"]} + +
    {@order.shipping_address["line2"]} +
    + + <:item :if={@order.shipping_address["city"]} title="City"> + {@order.shipping_address["city"]} + + <:item :if={@order.shipping_address["state"]} title="State"> + {@order.shipping_address["state"]} + + <:item :if={@order.shipping_address["postal_code"]} title="Postcode"> + {@order.shipping_address["postal_code"]} + + <:item :if={@order.shipping_address["country"]} title="Country"> + {@order.shipping_address["country"]} + + + <% else %> +

    No shipping address provided

    + <% end %> +
    +
    +
    + + <%!-- line items --%> +
    +
    +

    Items

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ProductVariantQtyUnit priceTotal
    {item.product_name}{item.variant_title}{item.quantity}{Cart.format_price(item.unit_price)}{Cart.format_price(item.unit_price * item.quantity)}
    Subtotal{Cart.format_price(@order.subtotal)}
    Total{Cart.format_price(@order.total)}
    +
    +
    +
    + """ + end + + defp status_badge(assigns) do + {bg, text, ring, icon} = + case assigns.status do + "paid" -> + {"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"} + + "pending" -> + {"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"} + + "failed" -> + {"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"} + + "refunded" -> + {"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-arrow-uturn-left-mini"} + + _ -> + {"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-question-mark-circle-mini"} + end + + assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon) + + ~H""" + + <.icon name={@icon} class="size-3" /> {@status} + + """ + end + + defp format_date(datetime) do + Calendar.strftime(datetime, "%d %b %Y %H:%M") + end +end diff --git a/lib/simpleshop_theme_web/live/admin_live/orders.ex b/lib/simpleshop_theme_web/live/admin_live/orders.ex new file mode 100644 index 0000000..d551391 --- /dev/null +++ b/lib/simpleshop_theme_web/live/admin_live/orders.ex @@ -0,0 +1,162 @@ +defmodule SimpleshopThemeWeb.AdminLive.Orders do + use SimpleshopThemeWeb, :live_view + + alias SimpleshopTheme.Orders + alias SimpleshopTheme.Cart + + @impl true + def mount(_params, _session, socket) do + counts = Orders.count_orders_by_status() + orders = Orders.list_orders() + + socket = + socket + |> assign(:page_title, "Orders") + |> assign(:status_filter, "all") + |> assign(:status_counts, counts) + |> assign(:order_count, length(orders)) + |> stream(:orders, orders) + + {:ok, socket} + end + + @impl true + def handle_event("filter", %{"status" => status}, socket) do + orders = Orders.list_orders(status: status) + + socket = + socket + |> assign(:status_filter, status) + |> assign(:order_count, length(orders)) + |> stream(:orders, orders, reset: true) + + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" + + <.header> + Orders + + +
    + <.filter_tab + status="all" + label="All" + count={total_count(@status_counts)} + active={@status_filter} + /> + <.filter_tab + status="paid" + label="Paid" + count={@status_counts["paid"]} + active={@status_filter} + /> + <.filter_tab + status="pending" + label="Pending" + count={@status_counts["pending"]} + active={@status_filter} + /> + <.filter_tab + status="failed" + label="Failed" + count={@status_counts["failed"]} + active={@status_filter} + /> + <.filter_tab + status="refunded" + label="Refunded" + count={@status_counts["refunded"]} + active={@status_filter} + /> +
    + + <.table + :if={@order_count > 0} + id="orders" + rows={@streams.orders} + row_item={fn {_id, order} -> order end} + row_click={fn {_id, order} -> JS.navigate(~p"/admin/orders/#{order}") end} + > + <:col :let={order} label="Order">{order.order_number} + <:col :let={order} label="Date">{format_date(order.inserted_at)} + <:col :let={order} label="Customer">{order.customer_email || "—"} + <:col :let={order} label="Total">{Cart.format_price(order.total)} + <:col :let={order} label="Status"><.status_badge status={order.payment_status} /> + + +
    + <.icon name="hero-inbox" class="size-12 mx-auto mb-4" /> +

    No orders yet

    +

    Orders will appear here once customers check out.

    +
    +
    + """ + end + + defp filter_tab(assigns) do + count = assigns[:count] || 0 + active = assigns.active == assigns.status + + assigns = assign(assigns, count: count, active: active) + + ~H""" + + """ + end + + defp status_badge(assigns) do + {bg, text, ring, icon} = + case assigns.status do + "paid" -> + {"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"} + + "pending" -> + {"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"} + + "failed" -> + {"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"} + + "refunded" -> + {"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-arrow-uturn-left-mini"} + + _ -> + {"bg-zinc-50", "text-zinc-600", "ring-zinc-500/10", "hero-question-mark-circle-mini"} + end + + assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon) + + ~H""" + + <.icon name={@icon} class="size-3" /> {@status} + + """ + end + + defp format_date(datetime) do + Calendar.strftime(datetime, "%d %b %Y %H:%M") + end + + defp total_count(counts) do + counts |> Map.values() |> Enum.sum() + end +end diff --git a/lib/simpleshop_theme_web/router.ex b/lib/simpleshop_theme_web/router.ex index 8b90000..7b96870 100644 --- a/lib/simpleshop_theme_web/router.ex +++ b/lib/simpleshop_theme_web/router.ex @@ -117,6 +117,8 @@ defmodule SimpleshopThemeWeb.Router do live "/admin/providers", ProviderLive.Index, :index live "/admin/providers/new", ProviderLive.Form, :new live "/admin/providers/:id/edit", ProviderLive.Form, :edit + live "/admin/orders", AdminLive.Orders, :index + live "/admin/orders/:id", AdminLive.OrderShow, :show live "/admin/settings", AdminLive.Settings, :index end diff --git a/test/simpleshop_theme/orders_test.exs b/test/simpleshop_theme/orders_test.exs new file mode 100644 index 0000000..86cf614 --- /dev/null +++ b/test/simpleshop_theme/orders_test.exs @@ -0,0 +1,65 @@ +defmodule SimpleshopTheme.OrdersTest do + use SimpleshopTheme.DataCase, async: false + + alias SimpleshopTheme.Orders + + import SimpleshopTheme.OrdersFixtures + + describe "list_orders/1" do + test "returns all orders" do + order1 = order_fixture() + order2 = order_fixture() + + orders = Orders.list_orders() + order_ids = Enum.map(orders, & &1.id) + + assert order1.id in order_ids + assert order2.id in order_ids + assert length(orders) == 2 + end + + test "filters by payment status" do + _pending = order_fixture() + paid = order_fixture(payment_status: "paid") + _failed = order_fixture(payment_status: "failed") + + orders = Orders.list_orders(status: "paid") + assert length(orders) == 1 + assert hd(orders).id == paid.id + end + + test "returns all when status is 'all'" do + order_fixture() + order_fixture(payment_status: "paid") + + orders = Orders.list_orders(status: "all") + assert length(orders) == 2 + end + + test "preloads items" do + order_fixture() + + [order] = Orders.list_orders() + assert Ecto.assoc_loaded?(order.items) + assert length(order.items) == 1 + end + end + + describe "count_orders_by_status/0" do + test "returns empty map when no orders" do + assert Orders.count_orders_by_status() == %{} + end + + test "counts orders by status" do + order_fixture() + order_fixture() + order_fixture(payment_status: "paid") + order_fixture(payment_status: "failed") + + counts = Orders.count_orders_by_status() + assert counts["pending"] == 2 + assert counts["paid"] == 1 + assert counts["failed"] == 1 + end + end +end diff --git a/test/simpleshop_theme_web/live/admin_live/orders_test.exs b/test/simpleshop_theme_web/live/admin_live/orders_test.exs new file mode 100644 index 0000000..06720ee --- /dev/null +++ b/test/simpleshop_theme_web/live/admin_live/orders_test.exs @@ -0,0 +1,119 @@ +defmodule SimpleshopThemeWeb.AdminLive.OrdersTest do + use SimpleshopThemeWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import SimpleshopTheme.AccountsFixtures + import SimpleshopTheme.OrdersFixtures + + setup do + user = user_fixture() + %{user: user} + end + + describe "unauthenticated" do + test "redirects to login", %{conn: conn} do + {:error, redirect} = live(conn, ~p"/admin/orders") + assert {:redirect, %{to: path}} = redirect + assert path == ~p"/users/log-in" + end + end + + describe "order list" do + setup %{conn: conn, user: user} do + conn = log_in_user(conn, user) + %{conn: conn} + end + + test "renders empty state when no orders", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/admin/orders") + + assert html =~ "No orders yet" + assert html =~ "Orders" + end + + test "renders orders table", %{conn: conn} do + order = order_fixture(payment_status: "paid", customer_email: "test@shop.com") + + {:ok, _view, html} = live(conn, ~p"/admin/orders") + + assert html =~ order.order_number + assert html =~ "test@shop.com" + assert html =~ "paid" + end + + test "filters by status", %{conn: conn} do + paid = order_fixture(payment_status: "paid") + _pending = order_fixture() + + {:ok, view, _html} = live(conn, ~p"/admin/orders") + + html = render_click(view, "filter", %{"status" => "paid"}) + + assert html =~ paid.order_number + end + + test "navigates to order detail", %{conn: conn} do + order = order_fixture(payment_status: "paid") + + {:ok, _view, html} = live(conn, ~p"/admin/orders") + + assert html =~ ~p"/admin/orders/#{order}" + end + end + + describe "order detail" do + setup %{conn: conn, user: user} do + conn = log_in_user(conn, user) + %{conn: conn} + end + + test "renders order details", %{conn: conn} do + order = order_fixture(payment_status: "paid", customer_email: "buyer@example.com") + + {:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}") + + assert html =~ order.order_number + assert html =~ "buyer@example.com" + assert html =~ "paid" + assert html =~ "Order details" + end + + test "shows line items", %{conn: conn} do + order = order_fixture(product_name: "Cool T-shirt", variant_title: "Blue / XL") + + {:ok, _view, html} = live(conn, ~p"/admin/orders/#{order}") + + assert html =~ "Cool T-shirt" + assert html =~ "Blue / XL" + end + + test "shows shipping address", %{conn: conn} do + order = order_fixture(payment_status: "paid") + + {:ok, updated_order} = + SimpleshopTheme.Orders.update_order(order, %{ + shipping_address: %{ + "name" => "Jane Doe", + "line1" => "42 Test Street", + "city" => "London", + "postal_code" => "SW1A 1AA", + "country" => "GB" + } + }) + + {:ok, _view, html} = live(conn, ~p"/admin/orders/#{updated_order}") + + assert html =~ "Jane Doe" + assert html =~ "42 Test Street" + assert html =~ "London" + assert html =~ "SW1A 1AA" + end + + test "redirects when order not found", %{conn: conn} do + fake_id = Ecto.UUID.generate() + + {:error, {:live_redirect, %{to: "/admin/orders"}}} = + live(conn, ~p"/admin/orders/#{fake_id}") + end + end +end diff --git a/test/support/fixtures/orders_fixtures.ex b/test/support/fixtures/orders_fixtures.ex new file mode 100644 index 0000000..0fe781d --- /dev/null +++ b/test/support/fixtures/orders_fixtures.ex @@ -0,0 +1,42 @@ +defmodule SimpleshopTheme.OrdersFixtures do + @moduledoc """ + Test helpers for creating orders. + """ + + alias SimpleshopTheme.Orders + + def order_fixture(attrs \\ %{}) do + attrs = Enum.into(attrs, %{}) + + items = [ + %{ + variant_id: "var_#{System.unique_integer([:positive])}", + name: Map.get(attrs, :product_name, "Test product"), + variant: Map.get(attrs, :variant_title, "Red / Large"), + price: Map.get(attrs, :unit_price, 1999), + quantity: Map.get(attrs, :quantity, 1) + } + ] + + order_attrs = %{ + items: items, + customer_email: Map.get(attrs, :customer_email, "customer@example.com"), + currency: Map.get(attrs, :currency, "gbp") + } + + {:ok, order} = Orders.create_order(order_attrs) + + case attrs[:payment_status] do + "paid" -> + {:ok, order} = Orders.mark_paid(order, "pi_test_#{System.unique_integer([:positive])}") + order + + "failed" -> + {:ok, order} = Orders.mark_failed(order) + order + + _ -> + order + end + end +end