berrypod/lib/berrypod_web/live/admin/dashboard.ex
jamey ae6cf209aa complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
  primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
  semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
  order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
  chart dimensions) and one JS.toggle target

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

303 lines
9.5 KiB
Elixir

defmodule BerrypodWeb.Admin.Dashboard do
use BerrypodWeb, :live_view
alias Berrypod.{Cart, Orders, Products, Settings}
@checklist_items [
%{key: :products_synced, label: "Sync your products", href: "/admin/providers"},
%{key: :stripe_connected, label: "Connect Stripe", href: "/admin/settings"},
%{key: :theme_customised, label: "Customise your theme", href: "/admin/theme"},
%{key: :has_orders, label: "Place a test order", href: "/"},
%{key: :site_live, label: "Go live", href: nil}
]
@impl true
def mount(_params, _session, socket) do
setup = Berrypod.Setup.setup_status()
status_counts = Orders.count_orders_by_status()
paid_count = Map.get(status_counts, "paid", 0)
recent_orders = Orders.list_orders(status: "paid") |> Enum.take(5)
{:ok,
socket
|> assign(:page_title, "Dashboard")
|> assign(:setup, setup)
|> assign(:show_checklist, show_checklist?(setup))
|> assign(:just_went_live, false)
|> assign(:paid_count, paid_count)
|> assign(:revenue, Orders.total_revenue())
|> assign(:product_count, Products.count_products())
|> assign(:recent_orders, recent_orders)}
end
# ── Events ──
@impl true
def handle_event("go_live", _params, socket) do
{:ok, _} = Settings.set_site_live(true)
setup = %{socket.assigns.setup | site_live: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:just_went_live, true)}
end
def handle_event("dismiss_checklist", _params, socket) do
{:ok, _} = Settings.put_setting("checklist_dismissed", true, "boolean")
setup = %{socket.assigns.setup | checklist_dismissed: true}
{:noreply,
socket
|> assign(:setup, setup)
|> assign(:show_checklist, false)}
end
# ── Render ──
@impl true
def render(assigns) do
~H"""
<.header>
Dashboard
</.header>
<%!-- Celebration after go-live --%>
<div :if={@just_went_live} class="setup-complete admin-card-spaced">
<.icon name="hero-check-badge" class="setup-complete-icon" />
<h2>Your shop is live!</h2>
<p>Customers can now browse and buy from your shop.</p>
<div class="setup-complete-actions">
<.link href={~p"/"} class="admin-btn admin-btn-primary">
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
</.link>
<.link navigate={~p"/admin/theme"} class="admin-btn admin-btn-secondary">
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
</.link>
</div>
</div>
<%!-- Launch checklist --%>
<.launch_checklist :if={@show_checklist and !@just_went_live} setup={@setup} />
<%!-- Stats --%>
<div class="admin-stats-grid">
<.stat_card
label="Orders"
value={@paid_count}
icon="hero-shopping-bag"
href={~p"/admin/orders"}
/>
<.stat_card
label="Revenue"
value={format_revenue(@revenue)}
icon="hero-banknotes"
href={~p"/admin/orders"}
/>
<.stat_card
label="Products"
value={@product_count}
icon="hero-cube"
href={~p"/admin/products"}
/>
</div>
<%!-- Recent orders --%>
<section class="dashboard-section">
<div class="dashboard-section-header">
<h2 class="admin-section-heading">Recent orders</h2>
<.link navigate={~p"/admin/orders"} class="dashboard-view-all">
View all &rarr;
</.link>
</div>
<%= if @recent_orders == [] do %>
<div class="dashboard-empty-orders">
<div class="dashboard-empty-icon">
<.icon name="hero-inbox" class="size-10" />
</div>
<p class="admin-text-medium">No orders yet</p>
<p class="admin-help-text">
Orders will appear here once customers check out.
</p>
</div>
<% else %>
<div class="dashboard-recent-orders">
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Date</th>
<th>Customer</th>
<th class="admin-cell-end">Total</th>
<th>Fulfilment</th>
</tr>
</thead>
<tbody>
<tr
:for={order <- @recent_orders}
phx-click={JS.navigate(~p"/admin/orders/#{order}")}
class="admin-table-row-clickable"
>
<td class="admin-text-medium">{order.order_number}</td>
<td>{format_date(order.inserted_at)}</td>
<td>{order.customer_email || "—"}</td>
<td class="admin-cell-end">{Cart.format_price(order.total)}</td>
<td><.fulfilment_pill status={order.fulfilment_status} /></td>
</tr>
</tbody>
</table>
</div>
<% end %>
</section>
"""
end
# ==========================================================================
# Launch checklist component
# ==========================================================================
attr :setup, :map, required: true
defp launch_checklist(assigns) do
items =
Enum.map(@checklist_items, fn item ->
Map.put(item, :done, Map.get(assigns.setup, item.key, false))
end)
done_count = Enum.count(items, & &1.done)
total = length(items)
progress_pct = round(done_count / total * 100)
can_go_live =
assigns.setup.provider_connected and assigns.setup.products_synced and
assigns.setup.stripe_connected
assigns =
assigns
|> assign(:items, items)
|> assign(:done_count, done_count)
|> assign(:total, total)
|> assign(:progress_pct, progress_pct)
|> assign(:can_go_live, can_go_live)
~H"""
<div class="admin-checklist admin-card-spaced">
<div class="admin-checklist-header">
<h2 class="admin-checklist-title">Launch checklist</h2>
<div class="admin-checklist-progress">
<span>{@done_count} of {@total}</span>
<div class="admin-checklist-bar">
<div class="admin-checklist-bar-fill" style={"width: #{@progress_pct}%"} />
</div>
</div>
</div>
<ul class="admin-checklist-items">
<li :for={item <- @items} class="admin-checklist-item">
<span class={["admin-checklist-check", item.done && "admin-checklist-check-done"]}>
<.icon :if={item.done} name="hero-check-mini" class="size-3" />
</span>
<span class={["admin-checklist-label", item.done && "admin-checklist-label-done"]}>
{item.label}
</span>
<span class="admin-checklist-action">
<%= if item.key == :site_live do %>
<button
phx-click="go_live"
disabled={!@can_go_live}
class="admin-btn admin-btn-primary admin-btn-sm"
>
<.icon name="hero-rocket-launch-mini" class="size-4" /> Go live
</button>
<% else %>
<.link
:if={!item.done}
navigate={item.href}
class="admin-btn admin-btn-secondary admin-btn-sm"
>
{if item.done, do: "View", else: "Start"} &rarr;
</.link>
<% end %>
</span>
</li>
</ul>
<div class="admin-checklist-footer">
<button
type="button"
phx-click="dismiss_checklist"
class="admin-btn admin-btn-ghost admin-btn-sm"
>
Dismiss
</button>
</div>
</div>
"""
end
# ==========================================================================
# Components
# ==========================================================================
attr :label, :string, required: true
attr :value, :any, required: true
attr :icon, :string, required: true
attr :href, :string, required: true
defp stat_card(assigns) do
~H"""
<.link navigate={@href} class="admin-card dashboard-stat-link">
<div class="admin-stat-card-body">
<div class="admin-stat-icon">
<.icon name={@icon} class="size-5" />
</div>
<div>
<p class="admin-stat-value">{@value}</p>
<p class="admin-stat-label">{@label}</p>
</div>
</div>
</.link>
"""
end
defp fulfilment_pill(assigns) do
{color_class, label} =
case assigns.status do
"unfulfilled" -> {"admin-status-pill-zinc", "unfulfilled"}
"submitted" -> {"admin-status-pill-blue", "submitted"}
"processing" -> {"admin-status-pill-amber", "processing"}
"shipped" -> {"admin-status-pill-purple", "shipped"}
"delivered" -> {"admin-status-pill-green", "delivered"}
"failed" -> {"admin-status-pill-red", "failed"}
_ -> {"admin-status-pill-zinc", assigns.status || ""}
end
assigns = assign(assigns, color_class: color_class, label: label)
~H"""
<span class={["admin-status-pill", @color_class]}>{@label}</span>
"""
end
# ==========================================================================
# Helpers
# ==========================================================================
defp show_checklist?(setup) do
not setup.site_live and not setup.checklist_dismissed
end
defp format_revenue(amount_pence) when is_integer(amount_pence) do
Cart.format_price(amount_pence)
end
defp format_revenue(_), do: "£0.00"
defp format_date(datetime) do
Calendar.strftime(datetime, "%d %b %Y")
end
end