Current section

Files

Jump to
phoenix_kit_ecommerce lib phoenix_kit_ecommerce web carts.ex
Raw

lib/phoenix_kit_ecommerce/web/carts.ex

defmodule PhoenixKitEcommerce.Web.Carts do
@moduledoc """
Carts admin list LiveView for E-Commerce module.
"""
use PhoenixKitEcommerce.Web, :live_view
import PhoenixKitWeb.Components.Core.TableDefault
alias PhoenixKit.Utils.Routes
alias PhoenixKitBilling.Currency
alias PhoenixKitEcommerce, as: Shop
@per_page 25
# Last-resort currency symbol used only when no default currency is
# configured in Billing (i.e. `@currency` resolved to nil at mount).
# When a currency struct is present we always defer to its own symbol.
@default_currency_symbol "$"
@impl true
def mount(_params, _session, socket) do
{carts, total} = Shop.list_carts_with_count(per_page: @per_page)
currency = Shop.get_default_currency()
socket =
socket
|> assign(:page_title, gettext("Shopping Carts"))
|> assign(:carts, carts)
|> assign(:total, total)
|> assign(:page, 1)
|> assign(:per_page, @per_page)
|> assign(:status_filter, nil)
|> assign(:search, "")
|> assign(:currency, currency)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
page = String.to_integer(params["page"] || "1")
status = params["status"]
search = params["search"] || ""
{carts, total} =
Shop.list_carts_with_count(
page: page,
per_page: @per_page,
status: status,
search: search
)
socket =
socket
|> assign(:carts, carts)
|> assign(:total, total)
|> assign(:page, page)
|> assign(:status_filter, status)
|> assign(:search, search)
{:noreply, socket}
end
@impl true
def handle_event("filter_status", %{"status" => status}, socket) do
status = if status == "", do: nil, else: status
{:noreply, push_patch(socket, to: build_url(socket.assigns, status: status, page: 1))}
end
@impl true
def handle_event("search", %{"search" => search}, socket) do
{:noreply, push_patch(socket, to: build_url(socket.assigns, search: search, page: 1))}
end
defp build_url(assigns, overrides) do
params =
%{
status: Keyword.get(overrides, :status, assigns.status_filter),
search: Keyword.get(overrides, :search, assigns.search),
page: Keyword.get(overrides, :page, assigns.page)
}
|> Enum.filter(fn {_k, v} -> v && v != "" end)
|> URI.encode_query()
if params == "" do
Routes.path("/admin/shop/carts")
else
Routes.path("/admin/shop/carts?#{params}")
end
end
@impl true
def render(assigns) do
~H"""
<div class="container flex-col mx-auto px-4 py-6 max-w-7xl">
<.admin_page_header back={Routes.path("/admin/shop")}>
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold text-base-content">{gettext("Shopping Carts")}</h1>
<p class="text-sm sm:text-base text-base-content/60 mt-0.5">{ngettext("1 cart total", "%{count} carts total", @total, count: @total)}</p>
</.admin_page_header>
<%!-- Controls Bar --%>
<div class="bg-base-200 rounded-lg p-6 mb-6">
<div class="flex flex-col lg:flex-row gap-4">
<%!-- Search --%>
<div class="flex-1">
<label class="label"><span class="label-text">{gettext("Search")}</span></label>
<form phx-submit="search" phx-change="search">
<input
type="text"
name="search"
value={@search}
placeholder={gettext("Search by email or session ID...")}
class="input input-bordered w-full focus:input-primary"
phx-debounce="300"
/>
</form>
</div>
<%!-- Status Filter --%>
<div class="w-full lg:w-48">
<label class="label"><span class="label-text">{gettext("Status")}</span></label>
<select
class="select select-bordered w-full focus:select-primary"
phx-change="filter_status"
name="status"
>
<option value="" selected={is_nil(@status_filter)}>{gettext("All Status")}</option>
<option value="active" selected={@status_filter == "active"}>{gettext("Active")}</option>
<option value="converted" selected={@status_filter == "converted"}>{gettext("Converted")}</option>
<option value="abandoned" selected={@status_filter == "abandoned"}>{gettext("Abandoned")}</option>
<option value="expired" selected={@status_filter == "expired"}>{gettext("Expired")}</option>
<option value="merged" selected={@status_filter == "merged"}>{gettext("Merged")}</option>
</select>
</div>
</div>
</div>
<%!-- Carts Table --%>
<.table_default id="carts-table" variant="zebra" class="w-full" toggleable={true} items={@carts}
card_fields={fn cart -> [
%{label: gettext("Items"),
value:
ngettext("1 item", "%{count} items", cart.items_count || 0,
count: cart.items_count || 0
)},
%{label: gettext("Total"), value: if(cart.total, do: Decimal.to_string(Decimal.round(cart.total, 2)), else: "-")},
%{label: gettext("Status"), value: cart.status || "-"},
%{label: gettext("Updated"), value: Calendar.strftime(cart.updated_at, "%Y-%m-%d %H:%M")}
] end}>
<:card_header :let={cart}>
<div class="font-bold">
<%= if cart.user do %>
{cart.user.email}
<% else %>
{gettext("Guest")}{String.slice(cart.session_id || "", 0, 16)}...
<% end %>
</div>
</:card_header>
<.table_default_header>
<.table_default_row>
<.table_default_header_cell>{gettext("Customer")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Items")}</.table_default_header_cell>
<.table_default_header_cell class="text-right">{gettext("Total")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Status")}</.table_default_header_cell>
<.table_default_header_cell>{gettext("Updated")}</.table_default_header_cell>
</.table_default_row>
</.table_default_header>
<.table_default_body>
<%= if @carts == [] do %>
<.table_default_row>
<.table_default_cell class="text-center py-12 text-base-content/50">
<.icon name="hero-shopping-cart" class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p class="text-lg">{gettext("No carts found")}</p>
<p class="text-sm">{gettext("Carts will appear here when customers add items")}</p>
</.table_default_cell>
</.table_default_row>
<% else %>
<%= for cart <- @carts do %>
<.table_default_row class="hover">
<.table_default_cell>
<%= if cart.user do %>
<div class="font-medium">{cart.user.email}</div>
<div class="text-xs text-base-content/50">User UUID: {cart.user.uuid}</div>
<% else %>
<div class="text-base-content/60">{gettext("Guest")}</div>
<div class="text-xs text-base-content/40 font-mono">
{String.slice(cart.session_id || "", 0, 16)}...
</div>
<% end %>
</.table_default_cell>
<.table_default_cell>
<span class="badge badge-neutral">
{ngettext("1 item", "%{count} items", cart.items_count || 0,
count: cart.items_count || 0
)}
</span>
<%= if cart.total_weight_grams && cart.total_weight_grams > 0 do %>
<span class="text-xs text-base-content/50 ml-2">
{format_weight(cart.total_weight_grams)}
</span>
<% end %>
</.table_default_cell>
<.table_default_cell class="text-right">
<div class="font-semibold">{format_price(cart.total, @currency)}</div>
<%= if Decimal.compare(cart.subtotal || Decimal.new("0"), cart.total || Decimal.new("0")) != :eq do %>
<div class="text-xs text-base-content/50">
Subtotal: {format_price(cart.subtotal, @currency)}
</div>
<% end %>
</.table_default_cell>
<.table_default_cell>
<span class={status_badge_class(cart.status)}>{cart.status}</span>
</.table_default_cell>
<.table_default_cell>
<div class="text-sm">{format_datetime(cart.updated_at)}</div>
<%= if cart.expires_at do %>
<div class="text-xs text-base-content/50">
Expires: {format_datetime(cart.expires_at)}
</div>
<% end %>
</.table_default_cell>
</.table_default_row>
<% end %>
<% end %>
</.table_default_body>
</.table_default>
<%!-- Pagination --%>
<%= if @total > @per_page do %>
<div class="flex justify-center mt-6">
<div class="join">
<%= for page_num <- 1..ceil(@total / @per_page) do %>
<.link
patch={build_url(assigns, page: page_num)}
class={["join-item btn btn-sm", if(@page == page_num, do: "btn-active")]}
>
{page_num}
</.link>
<% end %>
</div>
</div>
<% end %>
</div>
"""
end
defp status_badge_class("active"), do: "badge badge-success"
defp status_badge_class("converted"), do: "badge badge-info"
defp status_badge_class("abandoned"), do: "badge badge-warning"
defp status_badge_class("expired"), do: "badge badge-neutral"
defp status_badge_class("merged"), do: "badge badge-secondary"
defp status_badge_class(_), do: "badge"
defp format_price(nil, _currency), do: "-"
defp format_price(amount, nil) do
"#{@default_currency_symbol}#{Decimal.round(amount, 2)}"
end
defp format_price(amount, currency) do
Currency.format_amount(amount, currency)
end
defp format_weight(grams) when grams >= 1000, do: "#{Float.round(grams / 1000, 1)} kg"
defp format_weight(grams), do: "#{grams} g"
defp format_datetime(nil), do: "-"
defp format_datetime(datetime) do
Calendar.strftime(datetime, "%Y-%m-%d %H:%M")
end
end