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>
323 lines
11 KiB
Elixir
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;">
|
|
← 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 →
|
|
</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
|