All checks were successful
deploy / deploy (push) Successful in 1m26s
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>
340 lines
11 KiB
Elixir
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">
|
|
← 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 →
|
|
</.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
|