berrypod/lib/berrypod_web/live/admin/order_show.ex
jamey 67a26eb6b4
All checks were successful
deploy / deploy (push) Successful in 1m26s
add contextual prompts for skipped setup steps
Disable checkout when Stripe isn't connected (cart drawer, cart page,
and early guard in checkout controller to prevent orphaned orders).
Show amber warning on order detail when email isn't configured.
Fix pre-existing missing vertical spacing between page blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:02:49 +00:00

340 lines
11 KiB
Elixir

defmodule BerrypodWeb.Admin.OrderShow do
use BerrypodWeb, :live_view
alias Berrypod.{ActivityLog, Mailer, 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)
|> assign(:email_configured, Mailer.email_configured?())
{: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-back-link">
&larr; Orders
</.link>
<div class="admin-product-header">
<span class="admin-product-title">{@order.order_number}</span>
<.status_badge status={@order.payment_status} />
</div>
</.header>
<div :if={!@email_configured} class="admin-callout-warning admin-card-spaced">
<div class="admin-callout-warning-body">
<span class="admin-callout-warning-icon">
<.icon name="hero-exclamation-triangle" class="size-5" />
</span>
<div>
<p class="admin-callout-warning-title">
Order confirmation emails aren't being sent
</p>
<p class="admin-callout-warning-desc">
Set up an email provider to send order confirmations and shipping updates automatically.
<.link navigate={~p"/admin/settings/email"} class="admin-link">
Set up email &rarr;
</.link>
</p>
</div>
</div>
</div>
<div class="admin-grid order-detail-grid">
<%!-- 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 class="admin-code-sm">{@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 admin-section-desc-flush">No shipping address provided</p>
<% end %>
</div>
</div>
</div>
<%!-- timeline --%>
<div class="admin-card admin-card-spaced">
<div class="admin-card-body">
<div class="admin-row admin-row-between">
<h3 class="admin-card-title">Timeline</h3>
<.fulfilment_badge status={@order.fulfilment_status} />
</div>
<div class="admin-row order-timeline-actions">
<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 order-tracking"
>
<span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span>
<span class="admin-text-medium">{@order.tracking_number}</span>
<.external_link
:if={@order.tracking_url not in [nil, ""]}
href={@order.tracking_url}
class="admin-link"
>
Track shipment
</.external_link>
</div>
<.order_timeline entries={@timeline} />
</div>
</div>
<%!-- line items --%>
<div class="admin-card admin-card-spaced">
<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 class="admin-cell-end">Qty</th>
<th class="admin-cell-end">Unit price</th>
<th class="admin-cell-end">Total</th>
</tr>
</thead>
<tbody>
<tr :for={item <- @order.items}>
<td>{item.product_name}</td>
<td>{item.variant_title}</td>
<td class="admin-cell-numeric">{item.quantity}</td>
<td class="admin-cell-numeric">{Cart.format_price(item.unit_price)}</td>
<td class="admin-cell-numeric">{Cart.format_price(item.unit_price * item.quantity)}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4" class="admin-cell-end admin-text-medium">Subtotal</td>
<td class="admin-cell-end admin-text-medium">{Cart.format_price(@order.subtotal)}</td>
</tr>
<tr class="order-total-row">
<td colspan="4" class="admin-cell-end admin-text-bold">Total</td>
<td class="admin-cell-end admin-text-bold">{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 order-timeline-empty">
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