berrypod/lib/berrypod_web/live/admin/order_show.ex
jamey b7ec41b0cf refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.

Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:15:25 +00:00

323 lines
11 KiB
Elixir

defmodule BerrypodWeb.Admin.OrderShow do
use BerrypodWeb, :live_view
alias Berrypod.{ActivityLog, Orders}
alias Berrypod.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 ->
if connected?(socket), do: ActivityLog.subscribe(order.id)
timeline = ActivityLog.list_for_order(order.id)
socket =
socket
|> assign(:page_title, order.order_number)
|> assign(:order, order)
|> assign(:timeline, timeline)
{:ok, socket}
end
end
@impl true
def handle_info({:new_activity, entry}, socket) do
{:noreply, assign(socket, :timeline, socket.assigns.timeline ++ [entry])}
end
@impl true
def render(assigns) do
~H"""
<.header>
<.link navigate={~p"/admin/orders"} class="admin-link-subtle" style="font-weight: 400;">
&larr; Orders
</.link>
<div class="admin-row" style="--admin-row-gap: 0.75rem; margin-top: 0.25rem;">
<span style="font-size: 1.5rem; font-weight: 700;">{@order.order_number}</span>
<.status_badge status={@order.payment_status} />
</div>
</.header>
<div class="admin-grid" style="--admin-grid-min: 20rem; --admin-grid-gap: 1.5rem; margin-top: 1.5rem;">
<%!-- order info --%>
<div class="admin-card">
<div class="admin-card-body">
<h3 class="admin-card-title">Order details</h3>
<.list>
<:item title="Date">{format_date(@order.inserted_at)}</:item>
<:item title="Customer">{@order.customer_email || "—"}</:item>
<:item title="Payment status">
<.status_badge status={@order.payment_status} />
</:item>
<:item :if={@order.stripe_payment_intent_id} title="Stripe payment">
<code style="font-size: 0.75rem;">{@order.stripe_payment_intent_id}</code>
</:item>
<:item title="Currency">{String.upcase(@order.currency)}</:item>
</.list>
</div>
</div>
<%!-- shipping address --%>
<div class="admin-card">
<div class="admin-card-body">
<h3 class="admin-card-title">Shipping address</h3>
<%= if @order.shipping_address != %{} do %>
<.list>
<:item :if={@order.shipping_address["name"]} title="Name">
{@order.shipping_address["name"]}
</:item>
<:item :if={@order.shipping_address["line1"]} title="Address">
{@order.shipping_address["line1"]}
<span :if={@order.shipping_address["line2"]}>
<br />{@order.shipping_address["line2"]}
</span>
</:item>
<:item :if={@order.shipping_address["city"]} title="City">
{@order.shipping_address["city"]}
</:item>
<:item :if={@order.shipping_address["state"] not in [nil, ""]} title="State">
{@order.shipping_address["state"]}
</:item>
<:item :if={@order.shipping_address["postal_code"]} title="Postcode">
{@order.shipping_address["postal_code"]}
</:item>
<:item :if={@order.shipping_address["country"]} title="Country">
{@order.shipping_address["country"]}
</:item>
</.list>
<% else %>
<p class="admin-section-desc" style="margin-top: 0;">No shipping address provided</p>
<% end %>
</div>
</div>
</div>
<%!-- timeline --%>
<div class="admin-card" style="margin-top: 1.5rem;">
<div class="admin-card-body">
<div class="admin-row" style="justify-content: space-between;">
<h3 class="admin-card-title">Timeline</h3>
<.fulfilment_badge status={@order.fulfilment_status} />
</div>
<div class="admin-row" style="margin-top: 0.5rem; margin-bottom: 1rem;">
<button
:if={can_submit?(@order)}
phx-click="submit_to_provider"
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-paper-airplane-mini" class="size-4" />
{if @order.fulfilment_status == "failed",
do: "Retry submission",
else: "Submit to provider"}
</button>
<button
:if={can_refresh?(@order)}
phx-click="refresh_status"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
<.icon name="hero-arrow-path-mini" class="size-4" /> Refresh status
</button>
</div>
<div
:if={@order.tracking_number not in [nil, ""]}
class="admin-row"
style="margin-bottom: 1rem; font-size: 0.875rem;"
>
<span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span>
<span style="font-weight: 500;">{@order.tracking_number}</span>
<a
:if={@order.tracking_url not in [nil, ""]}
href={@order.tracking_url}
target="_blank"
rel="noopener"
class="admin-link"
>
Track shipment &rarr;
</a>
</div>
<.order_timeline entries={@timeline} />
</div>
</div>
<%!-- line items --%>
<div class="admin-card" style="margin-top: 1.5rem;">
<div class="admin-card-body">
<h3 class="admin-card-title">Items</h3>
<table class="admin-table admin-table-zebra">
<thead>
<tr>
<th>Product</th>
<th>Variant</th>
<th style="text-align: end;">Qty</th>
<th style="text-align: end;">Unit price</th>
<th style="text-align: end;">Total</th>
</tr>
</thead>
<tbody>
<tr :for={item <- @order.items}>
<td>{item.product_name}</td>
<td>{item.variant_title}</td>
<td style="text-align: end;">{item.quantity}</td>
<td style="text-align: end;">{Cart.format_price(item.unit_price)}</td>
<td style="text-align: end;">{Cart.format_price(item.unit_price * item.quantity)}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4" style="text-align: end; font-weight: 500;">Subtotal</td>
<td style="text-align: end; font-weight: 500;">{Cart.format_price(@order.subtotal)}</td>
</tr>
<tr style="font-size: 1.125rem;">
<td colspan="4" style="text-align: end; font-weight: 700;">Total</td>
<td style="text-align: end; font-weight: 700;">{Cart.format_price(@order.total)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
"""
end
@impl true
def handle_event("submit_to_provider", _params, socket) do
order = socket.assigns.order
case Orders.submit_to_provider(order) do
{:ok, updated} ->
socket =
socket
|> assign(:order, updated)
|> put_flash(:info, "Order submitted to provider")
{:noreply, socket}
{:error, _reason} ->
order = Orders.get_order(order.id)
socket =
socket
|> assign(:order, order)
|> put_flash(:error, order.fulfilment_error || "Submission failed")
{:noreply, socket}
end
end
def handle_event("refresh_status", _params, socket) do
order = socket.assigns.order
case Orders.refresh_fulfilment_status(order) do
{:ok, updated} ->
socket =
socket
|> assign(:order, updated)
|> put_flash(:info, "Status refreshed")
{:noreply, socket}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to refresh: #{inspect(reason)}")}
end
end
# ── Components ──
defp order_timeline(assigns) do
~H"""
<div :if={@entries == []} class="admin-section-desc" style="padding-block: 1rem; margin-top: 0;">
No activity recorded yet.
</div>
<ol :if={@entries != []} class="admin-timeline" id="order-timeline">
<li :for={entry <- @entries} class="admin-timeline-item">
<div class={["admin-timeline-dot", timeline_dot_class(entry.level)]}></div>
<div class="admin-timeline-content">
<p class="admin-timeline-message">{entry.message}</p>
<time class="admin-timeline-time" datetime={DateTime.to_iso8601(entry.occurred_at)}>
{format_timeline_time(entry.occurred_at)}
</time>
</div>
</li>
</ol>
"""
end
defp timeline_dot_class("error"), do: "admin-timeline-dot-error"
defp timeline_dot_class("warning"), do: "admin-timeline-dot-warning"
defp timeline_dot_class(_), do: "admin-timeline-dot-info"
defp format_timeline_time(datetime) do
today = Date.utc_today()
event_date = DateTime.to_date(datetime)
diff_days = Date.diff(today, event_date)
cond do
diff_days == 0 -> Calendar.strftime(datetime, "%H:%M")
diff_days < 7 -> Calendar.strftime(datetime, "%d %b %H:%M")
true -> Calendar.strftime(datetime, "%d %b %Y")
end
end
defp can_submit?(order) do
order.payment_status == "paid" and order.fulfilment_status in ["unfulfilled", "failed"]
end
defp can_refresh?(order) do
not is_nil(order.provider_order_id) and
order.fulfilment_status in ["submitted", "processing", "shipped"]
end
defp fulfilment_badge(assigns) do
{color, icon} =
case assigns.status do
"submitted" -> {"admin-status-pill-blue", "hero-paper-airplane-mini"}
"processing" -> {"admin-status-pill-amber", "hero-cog-6-tooth-mini"}
"shipped" -> {"admin-status-pill-purple", "hero-truck-mini"}
"delivered" -> {"admin-status-pill-green", "hero-check-circle-mini"}
"failed" -> {"admin-status-pill-red", "hero-x-circle-mini"}
"cancelled" -> {"admin-status-pill-zinc", "hero-no-symbol-mini"}
_ -> {"admin-status-pill-zinc", "hero-minus-circle-mini"}
end
assigns = assign(assigns, color: color, icon: icon)
~H"""
<span class={["admin-status-pill", @color]}>
<.icon name={@icon} class="size-3" /> {@status}
</span>
"""
end
defp status_badge(assigns) do
{color, icon} =
case assigns.status do
"paid" -> {"admin-status-pill-green", "hero-check-circle-mini"}
"pending" -> {"admin-status-pill-amber", "hero-clock-mini"}
"failed" -> {"admin-status-pill-red", "hero-x-circle-mini"}
"refunded" -> {"admin-status-pill-zinc", "hero-arrow-uturn-left-mini"}
_ -> {"admin-status-pill-zinc", "hero-question-mark-circle-mini"}
end
assigns = assign(assigns, color: color, icon: icon)
~H"""
<span class={["admin-status-pill", @color]}>
<.icon name={@icon} class="size-3" /> {@status}
</span>
"""
end
defp format_date(datetime) do
Calendar.strftime(datetime, "%d %b %Y %H:%M")
end
end