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>
This commit is contained in:
jamey
2026-03-01 21:40:21 +00:00
parent 22d3e36ed5
commit ae6cf209aa
26 changed files with 1343 additions and 1247 deletions

View File

@@ -62,8 +62,8 @@ defmodule BerrypodWeb.CoreComponents do
<p>{msg}</p>
</div>
<div class="admin-alert-spacer" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
<button type="button" class="admin-alert-close" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5" />
</button>
</div>
</div>
@@ -351,7 +351,7 @@ defmodule BerrypodWeb.CoreComponents do
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
class={@row_click && "admin-table-row-clickable"}
>
{render_slot(col, @row_item.(row))}
</td>

View File

@@ -46,7 +46,7 @@
<.link navigate={~p"/admin"} class="admin-brand">
Berrypod
</.link>
<p class="admin-text-secondary truncate" style="margin-top: 0.125rem;">
<p class="admin-text-secondary truncate">
{@current_scope.user.email}
</p>
</div>

View File

@@ -132,7 +132,7 @@ defmodule BerrypodWeb.Admin.Activity do
</.header>
<%!-- tabs --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<div class="admin-filter-row">
<button
phx-click="tab"
phx-value-tab="all"
@@ -156,7 +156,7 @@ defmodule BerrypodWeb.Admin.Activity do
Needs attention
<span
:if={@attention_count > 0}
class="admin-badge admin-badge-sm admin-badge-warning ml-1"
class="admin-badge admin-badge-sm admin-badge-warning admin-badge-count"
>
{@attention_count}
</span>
@@ -164,31 +164,36 @@ defmodule BerrypodWeb.Admin.Activity do
</div>
<%!-- category chips + search --%>
<div class="flex flex-wrap items-center gap-2 mb-4">
<.category_chip category={nil} active={@category} label="All" />
<.category_chip category="orders" active={@category} label="Orders" />
<.category_chip category="syncs" active={@category} label="Syncs" />
<.category_chip category="emails" active={@category} label="Emails" />
<.category_chip category="carts" active={@category} label="Carts" />
<div class="ml-auto">
<.form for={%{}} phx-submit="search" as={:search} class="flex gap-2">
<input
type="text"
name="search[query]"
value={@search}
placeholder="Search by order number"
class="admin-input admin-input-sm"
/>
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-magnifying-glass-mini" class="size-4" />
</button>
</.form>
<div class="admin-filter-row admin-filter-row-between">
<div class="admin-cluster">
<.category_chip category={nil} active={@category} label="All" />
<.category_chip category="orders" active={@category} label="Orders" />
<.category_chip category="syncs" active={@category} label="Syncs" />
<.category_chip category="emails" active={@category} label="Emails" />
<.category_chip category="carts" active={@category} label="Carts" />
</div>
<.form
for={%{}}
phx-submit="search"
as={:search}
class="admin-row"
>
<input
type="text"
name="search[query]"
value={@search}
placeholder="Search by order number"
class="admin-input admin-input-sm"
/>
<button type="submit" class="admin-btn admin-btn-sm admin-btn-ghost">
<.icon name="hero-magnifying-glass-mini" class="size-4" />
</button>
</.form>
</div>
<%!-- entries --%>
<div id="activity-entries" phx-update="stream">
<div id="activity-empty" class="hidden only:block text-sm text-base-content/60 py-8 text-center">
<div id="activity-empty" class="admin-stream-empty">
No activity to show.
</div>
<div :for={{dom_id, entry} <- @streams.entries} id={dom_id} class="admin-activity-row">
@@ -201,7 +206,7 @@ defmodule BerrypodWeb.Admin.Activity do
<.link
:if={entry.order_id}
navigate={~p"/admin/orders/#{entry.order_id}"}
class="text-primary hover:underline text-xs ml-1"
class="admin-activity-link"
>
View order &rarr;
</.link>
@@ -336,9 +341,9 @@ defmodule BerrypodWeb.Admin.Activity do
defp activity_icon("warning"), do: "hero-exclamation-triangle-mini"
defp activity_icon(_), do: "hero-check-circle-mini"
defp activity_icon_class("error"), do: "text-red-500"
defp activity_icon_class("warning"), do: "text-amber-500"
defp activity_icon_class(_), do: "text-green-500"
defp activity_icon_class("error"), do: "admin-activity-icon-error"
defp activity_icon_class("warning"), do: "admin-activity-icon-warning"
defp activity_icon_class(_), do: "admin-activity-icon-ok"
defp format_event_type(event_type) do
event_type

View File

@@ -144,10 +144,7 @@ defmodule BerrypodWeb.Admin.Analytics do
<.header>Analytics</.header>
<%!-- Period selector --%>
<div
class="analytics-periods"
style="display: flex; gap: 0.25rem; margin-top: 1rem; align-items: center;"
>
<div class="analytics-periods">
<button
:for={period <- ["today", "7d", "30d", "12m"]}
phx-click={
@@ -162,8 +159,7 @@ defmodule BerrypodWeb.Admin.Analytics do
id="analytics-export-link"
data-period={@period}
href={export_url(@period, @filters)}
class="admin-btn admin-btn-sm"
style="margin-left: auto;"
class="admin-btn admin-btn-sm analytics-export"
phx-hook="AnalyticsExport"
>
Export CSV
@@ -174,7 +170,7 @@ defmodule BerrypodWeb.Admin.Analytics do
<div
:if={@filters != %{}}
id="analytics-filters"
style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;"
class="analytics-filters"
>
<.filter_chip
:for={{dim, val} <- @filters}
@@ -184,15 +180,14 @@ defmodule BerrypodWeb.Admin.Analytics do
<button
:if={map_size(@filters) > 1}
phx-click="clear_filters"
class="admin-btn admin-btn-sm"
style="font-size: 0.75rem;"
class="admin-btn admin-btn-sm admin-text-secondary"
>
Clear all
</button>
</div>
<%!-- Stat cards --%>
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
<div class="admin-stats-grid admin-card-spaced">
<.stat_card
label="Unique visitors"
value={format_number(@visitors)}
@@ -221,15 +216,15 @@ defmodule BerrypodWeb.Admin.Analytics do
</div>
<%!-- Visitor trend chart --%>
<div class="admin-card" style="margin-top: 1.5rem; padding: 1rem;">
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
<div class="admin-card admin-card-spaced analytics-chart-card">
<h3 class="analytics-tab-heading">
Visitors over time
</h3>
<.bar_chart data={@trend_data} mode={@trend_mode} />
</div>
<%!-- Detail tabs --%>
<div style="display: flex; gap: 0.25rem; margin-top: 1.5rem; flex-wrap: wrap;">
<div class="analytics-tab-bar">
<button
:for={
tab <- [
@@ -249,7 +244,7 @@ defmodule BerrypodWeb.Admin.Analytics do
</div>
<%!-- Tab content --%>
<div class="admin-card" style="margin-top: 0.75rem; padding: 1rem;">
<div class="admin-card analytics-tab-panel">
<.tab_content tab={@tab} {assigns} />
</div>
"""
@@ -266,18 +261,16 @@ defmodule BerrypodWeb.Admin.Analytics do
defp stat_card(assigns) do
~H"""
<div class="admin-card">
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
<div class="admin-stat-card-body">
<div class="admin-stat-icon">
<.icon name={@icon} class="size-5" />
</div>
<div>
<div style="display: flex; align-items: baseline; gap: 0.5rem;">
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<div class="admin-stat-value-row">
<p class="admin-stat-value">{@value}</p>
<.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} />
</div>
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
{@label}
</p>
<p class="admin-stat-label">{@label}</p>
</div>
</div>
</div>
@@ -289,26 +282,24 @@ defmodule BerrypodWeb.Admin.Analytics do
defp delta_badge(%{delta: :new} = assigns) do
~H"""
<span style="font-size: 0.75rem; font-weight: 500; color: color-mix(in oklch, var(--color-base-content) 50%, transparent); white-space: nowrap;">
new
</span>
<span class="analytics-delta admin-text-secondary">new</span>
"""
end
defp delta_badge(assigns) do
{color, arrow} =
{color_class, arrow} =
cond do
assigns.delta > 0 && !assigns.invert -> {"var(--t-status-success, #22c55e)", ""}
assigns.delta > 0 && assigns.invert -> {"var(--t-status-error, #ef4444)", ""}
assigns.delta < 0 && !assigns.invert -> {"var(--t-status-error, #ef4444)", ""}
assigns.delta < 0 && assigns.invert -> {"var(--t-status-success, #22c55e)", ""}
true -> {"color-mix(in oklch, var(--color-base-content) 40%, transparent)", ""}
assigns.delta > 0 && !assigns.invert -> {"admin-icon-positive", ""}
assigns.delta > 0 && assigns.invert -> {"admin-text-error", ""}
assigns.delta < 0 && !assigns.invert -> {"admin-text-error", ""}
assigns.delta < 0 && assigns.invert -> {"admin-icon-positive", ""}
true -> {"admin-text-secondary", ""}
end
assigns = assign(assigns, color: color, arrow: arrow)
assigns = assign(assigns, color_class: color_class, arrow: arrow)
~H"""
<span style={"font-size: 0.75rem; font-weight: 500; color: #{@color}; white-space: nowrap;"}>
<span class={["analytics-delta", @color_class]}>
{@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"}
</span>
"""
@@ -326,13 +317,13 @@ defmodule BerrypodWeb.Admin.Analytics do
<span
data-filter-dimension={@dimension}
data-filter-value={@value}
style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-base-200, #e5e5e5); border-radius: 0.25rem;"
class="analytics-filter-chip"
>
{@label}
<button
phx-click="remove_filter"
phx-value-dimension={@dimension}
style="cursor: pointer; opacity: 0.6; line-height: 1;"
class="analytics-filter-remove"
aria-label={"Remove #{@label} filter"}
>
<.icon name="hero-x-mark" class="size-3" />
@@ -430,59 +421,42 @@ defmodule BerrypodWeb.Admin.Analytics do
)
~H"""
<div
:if={@data == []}
style="text-align: center; padding: 2rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
<div :if={@data == []} class="analytics-empty">
No data for this period
</div>
<div
:if={@data != []}
id="analytics-chart"
phx-hook="ChartTooltip"
style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem; position: relative;"
class="analytics-chart-grid"
>
<%!-- Tooltip --%>
<div
data-tooltip
style="display: none; position: absolute; top: -1.75rem; z-index: 10; padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 500; white-space: nowrap; background: var(--color-base-content, #1e1e1e); color: var(--color-base-100, #fff); border-radius: 0.25rem; pointer-events: none; font-variant-numeric: tabular-nums;"
>
</div>
<div data-tooltip class="analytics-tooltip"></div>
<%!-- Row 1: Y-axis labels + chart --%>
<div
class="analytics-y-labels"
style="display: flex; flex-direction: column; justify-content: space-between; align-items: flex-end; height: 8rem;"
>
<div class="analytics-y-labels">
<span>{format_number(@scale_max)}</span>
<span>{format_number(@scale_mid)}</span>
<span>0</span>
</div>
<div style="position: relative; height: 8rem;">
<div class="analytics-chart-area">
<%!-- Gridlines --%>
<div style="position: absolute; top: 50%; left: 0; right: 0; border-top: 1px dashed color-mix(in oklch, var(--color-base-content) 12%, transparent);">
</div>
<div style="position: absolute; bottom: 0; left: 0; right: 0; border-top: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);">
</div>
<div class="analytics-gridline-mid"></div>
<div class="analytics-gridline-bottom"></div>
<%!-- Bars container --%>
<div
data-bars
style={"display: flex; align-items: flex-end; height: 100%; gap: #{@bar_gap}px;"}
>
<div data-bars class="analytics-bars" style={"gap: #{@bar_gap}px;"}>
<div
:for={bar <- @bars}
data-label={bar.label}
data-visitors={bar.visitors}
style={"flex: 1; height: #{max(bar.height_pct, 0.5)}%; background: var(--color-primary, #4f46e5); opacity: 0.8; border-radius: 1px 1px 0 0; min-width: 0;"}
class="analytics-bar"
style={"height: #{max(bar.height_pct, 0.5)}%;"}
>
</div>
</div>
</div>
<%!-- Row 2: empty cell + X-axis labels --%>
<div></div>
<div
class="analytics-x-labels"
style="display: flex; justify-content: space-between; padding-top: 0.25rem;"
>
<div class="analytics-x-labels">
<span :for={bar <- @x_labels}>{bar.label}</span>
</div>
</div>
@@ -519,7 +493,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "pages"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top pages</h3>
<h3 class="analytics-tab-heading">Top pages</h3>
<.detail_table
rows={@top_pages}
empty_message="No page data yet"
@@ -529,7 +503,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Pageviews", key: :pageviews, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Entry pages</h3>
<h3 class="analytics-tab-heading-spaced">Entry pages</h3>
<.detail_table
rows={@entry_pages}
empty_message="No entry page data yet"
@@ -538,7 +512,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Sessions", key: :sessions, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Exit pages</h3>
<h3 class="analytics-tab-heading-spaced">Exit pages</h3>
<.detail_table
rows={@exit_pages}
empty_message="No exit page data yet"
@@ -552,7 +526,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "sources"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top sources</h3>
<h3 class="analytics-tab-heading">Top sources</h3>
<.detail_table
rows={@top_sources}
empty_message="No referrer data yet"
@@ -561,7 +535,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Top referrers</h3>
<h3 class="analytics-tab-heading-spaced">Top referrers</h3>
<.detail_table
rows={@top_referrers}
empty_message="No referrer data yet"
@@ -582,7 +556,7 @@ defmodule BerrypodWeb.Admin.Analytics do
assigns = assign(assigns, :country_rows, rows)
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
<h3 class="analytics-tab-heading">Countries</h3>
<.detail_table
rows={@country_rows}
empty_message="No country data yet"
@@ -596,7 +570,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "devices"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Browsers</h3>
<h3 class="analytics-tab-heading">Browsers</h3>
<.detail_table
rows={@browsers}
empty_message="No browser data yet"
@@ -605,9 +579,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">
Operating systems
</h3>
<h3 class="analytics-tab-heading-spaced">Operating systems</h3>
<.detail_table
rows={@oses}
empty_message="No OS data yet"
@@ -616,7 +588,7 @@ defmodule BerrypodWeb.Admin.Analytics do
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Screen sizes</h3>
<h3 class="analytics-tab-heading-spaced">Screen sizes</h3>
<.detail_table
rows={@screen_sizes}
empty_message="No screen data yet"
@@ -630,9 +602,7 @@ defmodule BerrypodWeb.Admin.Analytics do
defp tab_content(%{tab: "funnel"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
Conversion funnel
</h3>
<h3 class="analytics-tab-heading">Conversion funnel</h3>
<.funnel_chart funnel={@funnel} revenue={@revenue} />
"""
end
@@ -645,18 +615,15 @@ defmodule BerrypodWeb.Admin.Analytics do
defp detail_table(assigns) do
~H"""
<div
:if={@rows == []}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
<div :if={@rows == []} class="analytics-empty">
{@empty_message}
</div>
<table :if={@rows != []} class="admin-table" style="width: 100%;">
<table :if={@rows != []} class="admin-table">
<thead>
<tr>
<th
:for={col <- @columns}
style={col[:align] == :right && "text-align: right;"}
class={col[:align] == :right && "admin-cell-end"}
>
{col.label}
</th>
@@ -666,12 +633,11 @@ defmodule BerrypodWeb.Admin.Analytics do
<tr :for={row <- @rows}>
<td
:for={col <- @columns}
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"}
class={col[:align] == :right && "admin-cell-numeric"}
>
<%= if col[:filter] do %>
<span
class="admin-link"
style="cursor: pointer;"
phx-click="add_filter"
phx-value-dimension={elem(col.filter, 0)}
phx-value-value={Map.get(row, elem(col.filter, 1))}
@@ -721,35 +687,25 @@ defmodule BerrypodWeb.Admin.Analytics do
assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate)
~H"""
<div
:if={@funnel.product_views == 0}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
<div :if={@funnel.product_views == 0} class="analytics-empty">
No funnel data yet
</div>
<div :if={@funnel.product_views > 0} style="display: flex; flex-direction: column; gap: 0.5rem;">
<div :for={step <- @steps} style="display: flex; align-items: center; gap: 0.75rem;">
<div style="width: 7rem; font-size: 0.8125rem; text-align: right; flex-shrink: 0;">
{step.label}
</div>
<div style={"flex: 0 0 #{step.width_pct}%; height: 2rem; background: var(--color-primary, #4f46e5); border-radius: 0.25rem; opacity: #{1 - step.index * 0.15}; display: flex; align-items: center; padding-left: 0.5rem;"}>
<span style="font-size: 0.75rem; font-weight: 600; color: white;">
{format_number(step.count)}
</span>
</div>
<span
:if={step.index > 0}
style="font-size: 0.8125rem; font-weight: 600;"
<div :if={@funnel.product_views > 0} class="analytics-funnel">
<div :for={step <- @steps} class="analytics-funnel-step">
<div class="analytics-funnel-label">{step.label}</div>
<div
class="analytics-funnel-bar"
style={"flex: 0 0 #{step.width_pct}%; opacity: #{1 - step.index * 0.15};"}
>
<span class="analytics-funnel-value">{format_number(step.count)}</span>
</div>
<span :if={step.index > 0} class="analytics-funnel-rate">
{step.overall_rate}%
</span>
</div>
<div style="margin-top: 0.75rem; font-size: 0.875rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
<span style="font-weight: 600;">{@conversion_rate}% overall conversion</span>
<span
:if={@revenue > 0}
style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
>
<div class="analytics-funnel-summary">
<span class="admin-text-bold">{@conversion_rate}% overall conversion</span>
<span :if={@revenue > 0} class="admin-text-secondary">
· Revenue: {Cart.format_price(@revenue)}
</span>
</div>

View File

@@ -63,11 +63,11 @@ defmodule BerrypodWeb.Admin.Dashboard do
</.header>
<%!-- Celebration after go-live --%>
<div :if={@just_went_live} class="setup-complete" style="margin-top: 1.5rem;">
<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 style="display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap;">
<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>
@@ -103,36 +103,33 @@ defmodule BerrypodWeb.Admin.Dashboard do
</div>
<%!-- Recent orders --%>
<section style="margin-top: 2rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem;">
<h2 style="font-size: 1.125rem; font-weight: 600;">Recent orders</h2>
<.link
navigate={~p"/admin/orders"}
style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
>
<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 style="border: 1px solid var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 2rem; text-align: center; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
<div style="margin: 0 auto 0.75rem; width: 2.5rem; opacity: 0.3;">
<div class="dashboard-empty-orders">
<div class="dashboard-empty-icon">
<.icon name="hero-inbox" class="size-10" />
</div>
<p style="font-weight: 500;">No orders yet</p>
<p style="font-size: 0.875rem; margin-top: 0.25rem;">
<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 style="overflow-x: auto;">
<div class="dashboard-recent-orders">
<table class="admin-table">
<thead>
<tr>
<th>Order</th>
<th>Date</th>
<th>Customer</th>
<th style="text-align: right;">Total</th>
<th class="admin-cell-end">Total</th>
<th>Fulfilment</th>
</tr>
</thead>
@@ -140,12 +137,12 @@ defmodule BerrypodWeb.Admin.Dashboard do
<tr
:for={order <- @recent_orders}
phx-click={JS.navigate(~p"/admin/orders/#{order}")}
style="cursor: pointer;"
class="admin-table-row-clickable"
>
<td style="font-weight: 500;">{order.order_number}</td>
<td class="admin-text-medium">{order.order_number}</td>
<td>{format_date(order.inserted_at)}</td>
<td>{order.customer_email || "—"}</td>
<td style="text-align: right;">{Cart.format_price(order.total)}</td>
<td class="admin-cell-end">{Cart.format_price(order.total)}</td>
<td><.fulfilment_pill status={order.fulfilment_status} /></td>
</tr>
</tbody>
@@ -185,7 +182,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
|> assign(:can_go_live, can_go_live)
~H"""
<div class="admin-checklist" style="margin-top: 1.5rem;">
<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">
@@ -252,20 +249,14 @@ defmodule BerrypodWeb.Admin.Dashboard do
defp stat_card(assigns) do
~H"""
<.link
navigate={@href}
class="admin-card"
style="display: block; text-decoration: none;"
>
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
<.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 style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
{@label}
</p>
<p class="admin-stat-value">{@value}</p>
<p class="admin-stat-label">{@label}</p>
</div>
</div>
</.link>
@@ -273,23 +264,21 @@ defmodule BerrypodWeb.Admin.Dashboard do
end
defp fulfilment_pill(assigns) do
{color, label} =
{color_class, label} =
case assigns.status do
"unfulfilled" -> {"var(--color-base-200, #e5e5e5)", "unfulfilled"}
"submitted" -> {"#dbeafe", "submitted"}
"processing" -> {"#fef3c7", "processing"}
"shipped" -> {"#f3e8ff", "shipped"}
"delivered" -> {"#dcfce7", "delivered"}
"failed" -> {"#fee2e2", "failed"}
_ -> {"var(--color-base-200, #e5e5e5)", assigns.status || ""}
"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: color, label: label)
assigns = assign(assigns, color_class: color_class, label: label)
~H"""
<span style={"display: inline-flex; border-radius: 9999px; padding: 0.125rem 0.5rem; font-size: 0.75rem; font-weight: 500; background: #{@color};"}>
{@label}
</span>
<span class={["admin-status-pill", @color_class]}>{@label}</span>
"""
end

View File

@@ -200,7 +200,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
@impl true
def render(assigns) do
~H"""
<div class="max-w-2xl">
<div class="admin-content-medium">
<.header>
Email settings
<:subtitle>
@@ -211,14 +211,16 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</.header>
<%= if @env_locked do %>
<div class="mt-6 rounded-md bg-amber-50 p-4 ring-1 ring-amber-600/10 ring-inset">
<div class="flex gap-3">
<.icon name="hero-lock-closed" class="size-5 text-amber-600 shrink-0 mt-0.5" />
<div class="admin-callout-warning">
<div class="admin-callout-warning-body">
<span class="admin-callout-warning-icon">
<.icon name="hero-lock-closed" class="size-5" />
</span>
<div>
<p class="text-sm font-medium text-amber-800">
<p class="admin-callout-warning-title">
Controlled by environment variables
</p>
<p class="mt-1 text-sm text-amber-700">
<p class="admin-callout-warning-desc">
Email is configured via <code>SMTP_HOST</code> and related env vars.
Remove them to configure email from this page instead.
</p>
@@ -227,7 +229,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</div>
<% end %>
<section class="mt-8">
<section class="admin-section">
<.form for={@form} phx-change="change_adapter" phx-submit="save">
<div id="email-provider-cards" phx-hook="CardRadioScroll">
<.card_radio_group
@@ -244,24 +246,24 @@ defmodule BerrypodWeb.Admin.EmailSettings do
<% selected = @adapter_key == adapter.key %>
<div
id={"adapter-config-#{adapter.key}"}
class="mt-6 space-y-4"
class="admin-adapter-config"
hidden={!selected}
data-adapter={adapter.key}
>
<div>
<h3 class="text-base font-semibold">
<h3 class="admin-section-subheading">
{adapter.name}
<a
:if={adapter.url}
href={adapter.url}
target="_blank"
rel="noopener"
class="text-sm font-normal text-base-content/50 hover:text-base-content/80"
class="admin-link-subtle admin-adapter-link"
>
&nearr;
</a>
</h3>
<p class="text-sm text-base-content/60">{adapter.description}</p>
<p class="admin-section-desc">{adapter.description}</p>
</div>
<.input
name="email[from_address]"
@@ -279,7 +281,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
/>
<% end %>
<%= unless @env_locked do %>
<div class="flex items-center gap-3">
<div class="admin-row admin-row-lg">
<.button phx-disable-with="Saving..." disabled={!selected}>
Save settings
</.button>
@@ -288,7 +290,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
type="button"
phx-click="disconnect"
data-confirm="Remove email configuration? Transactional emails will stop being sent."
class="text-sm text-red-600 hover:text-red-800"
class="admin-link-danger"
>
Disconnect
</button>
@@ -301,17 +303,17 @@ defmodule BerrypodWeb.Admin.EmailSettings do
</section>
<%= if @email_configured do %>
<section class="mt-8 border-t border-base-200 pt-6">
<h2 class="text-lg font-semibold">Test email</h2>
<p class="mt-1 text-sm text-base-content/60">
<section class="admin-section-bordered">
<h2 class="admin-section-heading">Test email</h2>
<p class="admin-help-text">
Send a test email to <strong>{@current_scope.user.email}</strong>
to verify delivery works.
</p>
<div class="mt-4">
<div class="admin-section-body">
<button
phx-click="send_test"
disabled={@sending_test}
class="inline-flex items-center gap-2 rounded-md bg-base-200 px-3 py-2 text-sm font-medium text-base-content hover:bg-base-300 ring-1 ring-base-300 ring-inset"
class="admin-btn admin-btn-outline"
>
<.icon name="hero-paper-airplane" class="size-4" />
{if @sending_test, do: "Sending...", else: "Send test email"}
@@ -341,7 +343,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do
disabled={@disabled}
/>
<%= if @value && !@disabled do %>
<p class="text-xs text-base-content/60 mt-1">
<p class="admin-help-text">
Current: <code>{@value}</code> — leave blank to keep existing value
</p>
<% end %>

View File

@@ -260,7 +260,7 @@ defmodule BerrypodWeb.Admin.Media do
name="value"
value={@upload_alt}
placeholder="Alt text (recommended)"
class="admin-input flex-1"
class="admin-input admin-input-fill"
phx-debounce="200"
/>
</div>
@@ -272,12 +272,12 @@ defmodule BerrypodWeb.Admin.Media do
</div>
<% end %>
<%= for err <- upload_errors(@uploads.media_upload) do %>
<p class="text-error text-sm">{Phoenix.Naming.humanize(err)}</p>
<p class="admin-error">{Phoenix.Naming.humanize(err)}</p>
<% end %>
</div>
<%!-- filter bar --%>
<div class="flex gap-2 mt-6 mb-4 flex-wrap items-center">
<div class="admin-filter-row">
<form phx-change="filter_type" class="contents">
<select name="type" class="admin-select">
<option value="" selected={is_nil(@filter_type)}>All types</option>
@@ -294,7 +294,7 @@ defmodule BerrypodWeb.Admin.Media do
value={@filter_search}
phx-keyup="filter_search"
phx-debounce="300"
class="admin-input flex-1"
class="admin-input admin-input-fill"
/>
<button
phx-click="toggle_orphans"
@@ -342,7 +342,7 @@ defmodule BerrypodWeb.Admin.Media do
<span class="media-card-filename" title={image.filename}>{image.filename}</span>
<div class="media-card-meta">
<span class={type_badge_class(image.image_type)}>{image.image_type}</span>
<span class="text-xs">{format_file_size(image.file_size)}</span>
<span class="media-card-size">{format_file_size(image.file_size)}</span>
</div>
<span
:if={!image.alt || image.alt == ""}
@@ -419,14 +419,14 @@ defmodule BerrypodWeb.Admin.Media do
<div class="media-detail-actions">
<%= if @confirm_delete do %>
<p class="text-sm text-error">
<p class="admin-error">
<%= if @selected_usages != [] do %>
This image is in use. Deleting it may break pages.
<% else %>
Are you sure?
<% end %>
</p>
<div class="flex gap-2">
<div class="admin-row">
<button phx-click="delete_image" class="admin-btn admin-btn-sm admin-btn-danger">
Yes, delete
</button>
@@ -437,7 +437,7 @@ defmodule BerrypodWeb.Admin.Media do
<% else %>
<button
phx-click="confirm_delete"
class="admin-btn admin-btn-sm admin-btn-ghost text-error"
class="admin-btn admin-btn-sm admin-btn-ghost admin-text-error"
>
<.icon name="hero-trash" class="size-4" /> Delete image
</button>

View File

@@ -111,11 +111,10 @@ defmodule BerrypodWeb.Admin.Navigation do
<:subtitle>Configure the links in your shop header and footer.</:subtitle>
</.header>
<p :if={@dirty} class="admin-badge admin-badge-warning mt-4">
Unsaved changes
</p>
<div class="mt-6 space-y-8" style="max-width: 40rem;">
<div class="admin-nav-layout">
<p :if={@dirty} class="admin-badge admin-badge-warning">
Unsaved changes
</p>
<.nav_section
title="Header navigation"
section="header"
@@ -130,10 +129,10 @@ defmodule BerrypodWeb.Admin.Navigation do
custom_pages={@custom_pages}
/>
<div class="flex gap-3">
<div class="admin-row admin-row-lg">
<button
phx-click="save"
class={["admin-btn admin-btn-primary", !@dirty && "opacity-50"]}
class="admin-btn admin-btn-primary"
disabled={!@dirty}
>
Save
@@ -153,11 +152,11 @@ defmodule BerrypodWeb.Admin.Navigation do
defp nav_section(assigns) do
~H"""
<section>
<h3 class="text-sm font-semibold uppercase tracking-wider text-base-content/50 mb-3">
<h3 class="admin-nav-section-heading">
{@title}
</h3>
<div class="space-y-2">
<div class="admin-stack admin-stack-sm">
<div :if={@items != []} class="nav-editor-labels" aria-hidden="true">
<span>Label</span>
<span>Path</span>
@@ -228,11 +227,14 @@ defmodule BerrypodWeb.Admin.Navigation do
</div>
</div>
<div :if={@items == []} class="text-sm text-base-content/50 py-4">
<div
:if={@items == []}
class="admin-nav-empty"
>
No items yet.
</div>
<div class="mt-3 flex gap-2">
<div class="admin-nav-actions">
<button
phx-click="add_item"
phx-value-section={@section}
@@ -240,7 +242,7 @@ defmodule BerrypodWeb.Admin.Navigation do
>
<.icon name="hero-plus" class="size-4" /> Add link
</button>
<div :if={@custom_pages != []} class="relative" id={"add-page-#{@section}"}>
<div :if={@custom_pages != []} class="nav-editor-dropdown-wrap" id={"add-page-#{@section}"}>
<button
phx-click={Phoenix.LiveView.JS.toggle(to: "#page-menu-#{@section}")}
class="admin-btn admin-btn-sm admin-btn-outline"
@@ -260,7 +262,9 @@ defmodule BerrypodWeb.Admin.Navigation do
class="nav-editor-dropdown-item"
>
{page.title}
<span class="text-xs text-base-content/40">/{page.slug}</span>
<span class="nav-editor-dropdown-slug">
/{page.slug}
</span>
</button>
</div>
</div>

View File

@@ -194,12 +194,12 @@ defmodule BerrypodWeb.Admin.Newsletter do
defp overview_tab(assigns) do
~H"""
<div class="admin-stack" style="--admin-stack-gap: 1.5rem;">
<div class="admin-stack admin-stack-lg">
<div class="admin-card">
<div class="admin-card-body admin-row" style="--admin-row-gap: 1rem;">
<div style="flex: 1;">
<h3 style="font-weight: 500;">Newsletter signups</h3>
<p class="admin-section-desc" style="margin-top: 0.125rem;">
<div class="admin-card-body admin-row admin-row-xl">
<div class="admin-input-fill">
<h3 class="admin-text-medium">Newsletter signups</h3>
<p class="admin-section-desc">
When enabled, the newsletter signup block on your shop pages will collect email addresses with double opt-in confirmation.
</p>
</div>
@@ -233,18 +233,16 @@ defmodule BerrypodWeb.Admin.Newsletter do
</div>
</div>
<div class="admin-row" style="--admin-row-gap: 0.75rem;">
<div class="admin-row admin-row-lg">
<.link
navigate={~p"/admin/newsletter?tab=subscribers"}
class="admin-link"
style="font-weight: 500;"
class="admin-link admin-text-medium"
>
View subscribers
</.link>
<.link
navigate={~p"/admin/newsletter?tab=campaigns"}
class="admin-link"
style="font-weight: 500;"
class="admin-link admin-text-medium"
>
View campaigns
</.link>
@@ -265,7 +263,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
defp subscribers_tab(assigns) do
~H"""
<div>
<div class="admin-row" style="justify-content: space-between; margin-bottom: 1rem;">
<div class="admin-filter-row admin-filter-row-between">
<div class="admin-cluster">
<.filter_pill
status="all"
@@ -297,7 +295,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
</.link>
</div>
<form phx-change="search_subscribers" style="margin-bottom: 1rem;">
<form phx-change="search_subscribers" class="admin-filter-row">
<.input
name="search"
value={@search}
@@ -356,7 +354,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
defp campaigns_tab(assigns) do
~H"""
<div>
<div style="display: flex; justify-content: flex-end; margin-bottom: 1rem;">
<div class="admin-tab-actions">
<.link navigate={~p"/admin/newsletter/campaigns/new"} class="admin-btn admin-btn-primary">
<.icon name="hero-plus" class="size-4" /> New campaign
</.link>
@@ -426,7 +424,9 @@ defmodule BerrypodWeb.Admin.Newsletter do
]}
>
{@label}
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
<span :if={@count > 0} class="admin-badge admin-badge-sm admin-badge-count">
{@count}
</span>
</button>
"""
end

View File

@@ -156,9 +156,9 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</:subtitle>
</.header>
<div class="mt-6 max-w-2xl">
<div class="admin-content-medium admin-section">
<.form for={@form} phx-change="validate" phx-submit="save_draft">
<div class="space-y-4">
<div class="admin-stack">
<.input
field={@form[:subject]}
type="text"
@@ -178,14 +178,17 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
placeholder="Hello!\n\nYour newsletter content here.\n\nUnsubscribe: {{unsubscribe_url}}"
/>
<p :if={!readonly?(@campaign)} class="text-sm text-base-content/60">
<p
:if={!readonly?(@campaign)}
class="admin-help-text"
>
Use <code>{"{{unsubscribe_url}}"}</code>
to insert the unsubscribe link. This is required for GDPR compliance.
</p>
<p
:if={missing_unsubscribe_url?(@form[:body].value) && !readonly?(@campaign)}
class="flex items-center gap-2 text-sm text-amber-700"
class="admin-warning-text"
>
<.icon name="hero-exclamation-triangle" class="size-4 shrink-0" /> Body is missing
<code>{"{{unsubscribe_url}}"}</code>
@@ -193,15 +196,17 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</p>
<%= if @form[:body].value && @form[:body].value != "" do %>
<details class="mt-4">
<summary class="text-sm font-medium cursor-pointer">Preview</summary>
<pre class="mt-2 p-4 bg-base-200 rounded-lg text-sm whitespace-pre-wrap overflow-auto max-h-64">{preview_body(@form[:body].value)}</pre>
<details>
<summary class="admin-preview-summary">
Preview
</summary>
<pre class="admin-preview-body">{preview_body(@form[:body].value)}</pre>
</details>
<% end %>
<div
:if={!readonly?(@campaign)}
class="flex items-center gap-3 pt-4 border-t border-base-200"
class="admin-campaign-actions"
>
<.button type="submit">
Save draft
@@ -219,8 +224,7 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
type="button"
phx-click="send_now"
data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"}
class="admin-btn admin-btn-primary"
style="background-color: var(--color-green-600)"
class="admin-btn admin-btn-primary admin-btn-success"
disabled={@subscriber_count == 0}
>
<.icon name="hero-paper-airplane" class="size-4" /> Send now
@@ -234,7 +238,7 @@ defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
</.link>
</div>
<div :if={readonly?(@campaign)} class="pt-4 border-t border-base-200">
<div :if={readonly?(@campaign)} class="admin-readonly-actions">
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-btn admin-btn-ghost">
<.icon name="hero-arrow-left" class="size-4" /> Back to campaigns
</.link>

View File

@@ -39,19 +39,16 @@ defmodule BerrypodWeb.Admin.OrderShow do
def render(assigns) do
~H"""
<.header>
<.link navigate={~p"/admin/orders"} class="admin-link-subtle" style="font-weight: 400;">
<.link navigate={~p"/admin/orders"} class="admin-back-link">
&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>
<div class="admin-product-header">
<span class="admin-product-title">{@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;"
>
<div class="admin-grid order-detail-grid">
<%!-- order info --%>
<div class="admin-card">
<div class="admin-card-body">
@@ -63,7 +60,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
<.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>
<code class="admin-code-sm">{@order.stripe_payment_intent_id}</code>
</:item>
<:item title="Currency">{String.upcase(@order.currency)}</:item>
</.list>
@@ -99,20 +96,20 @@ defmodule BerrypodWeb.Admin.OrderShow do
</:item>
</.list>
<% else %>
<p class="admin-section-desc" style="margin-top: 0;">No shipping address provided</p>
<p class="admin-section-desc admin-section-desc-flush">No shipping address provided</p>
<% end %>
</div>
</div>
</div>
<%!-- timeline --%>
<div class="admin-card" style="margin-top: 1.5rem;">
<div class="admin-card admin-card-spaced">
<div class="admin-card-body">
<div class="admin-row" style="justify-content: space-between;">
<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" style="margin-top: 0.5rem; margin-bottom: 1rem;">
<div class="admin-row order-timeline-actions">
<button
:if={can_submit?(@order)}
phx-click="submit_to_provider"
@@ -133,11 +130,10 @@ defmodule BerrypodWeb.Admin.OrderShow do
</div>
<div
:if={@order.tracking_number not in [nil, ""]}
class="admin-row"
style="margin-bottom: 1rem; font-size: 0.875rem;"
class="admin-row order-tracking"
>
<span class="admin-text-secondary"><.icon name="hero-truck-mini" class="size-4" /></span>
<span style="font-weight: 500;">{@order.tracking_number}</span>
<span class="admin-text-medium">{@order.tracking_number}</span>
<a
:if={@order.tracking_url not in [nil, ""]}
href={@order.tracking_url}
@@ -153,7 +149,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
</div>
<%!-- line items --%>
<div class="admin-card" style="margin-top: 1.5rem;">
<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">
@@ -161,28 +157,28 @@ defmodule BerrypodWeb.Admin.OrderShow do
<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>
<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 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>
<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" style="text-align: end; font-weight: 500;">Subtotal</td>
<td style="text-align: end; font-weight: 500;">{Cart.format_price(@order.subtotal)}</td>
<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 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 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>
@@ -237,7 +233,7 @@ defmodule BerrypodWeb.Admin.OrderShow do
defp order_timeline(assigns) do
~H"""
<div :if={@entries == []} class="admin-section-desc" style="padding-block: 1rem; margin-top: 0;">
<div :if={@entries == []} class="admin-section-desc order-timeline-empty">
No activity recorded yet.
</div>
<ol :if={@entries != []} class="admin-timeline" id="order-timeline">

View File

@@ -46,7 +46,7 @@ defmodule BerrypodWeb.Admin.Orders do
Orders
</.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<div class="admin-filter-row">
<.filter_tab
status="all"
label="All"
@@ -98,10 +98,10 @@ defmodule BerrypodWeb.Admin.Orders do
<.admin_pagination :if={@order_count > 0} page={@pagination} patch={~p"/admin/orders"} />
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No orders yet</p>
<p class="text-sm mt-1">Orders will appear here once customers check out.</p>
<div :if={@order_count == 0} class="admin-empty-state">
<.icon name="hero-inbox" class="admin-empty-state-icon" />
<p class="admin-empty-state-title">No orders yet</p>
<p class="admin-empty-state-text">Orders will appear here once customers check out.</p>
</div>
"""
end
@@ -123,41 +123,27 @@ defmodule BerrypodWeb.Admin.Orders do
]}
>
{@label}
<span :if={@count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
<span :if={@count > 0} class="admin-badge admin-badge-sm admin-badge-count">
{@count}
</span>
</button>
"""
end
defp status_badge(assigns) do
{bg, text, ring, icon} =
{color, icon} =
case assigns.status do
"paid" ->
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
"pending" ->
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-clock-mini"}
"failed" ->
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
"refunded" ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-arrow-uturn-left-mini"}
_ ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-question-mark-circle-mini"}
"paid" -> {"green", "hero-check-circle-mini"}
"pending" -> {"amber", "hero-clock-mini"}
"failed" -> {"red", "hero-x-circle-mini"}
"refunded" -> {"zinc", "hero-arrow-uturn-left-mini"}
_ -> {"zinc", "hero-question-mark-circle-mini"}
end
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
assigns = assign(assigns, color: color, icon: icon)
~H"""
<span class={[
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
<span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>
<.icon name={@icon} class="size-3" /> {@status}
</span>
"""
@@ -168,41 +154,21 @@ defmodule BerrypodWeb.Admin.Orders do
end
defp fulfilment_badge(assigns) do
{bg, text, ring, icon} =
{color, icon} =
case assigns.status do
"submitted" ->
{"bg-blue-50", "text-blue-700", "ring-blue-600/20", "hero-paper-airplane-mini"}
"processing" ->
{"bg-amber-50", "text-amber-700", "ring-amber-600/20", "hero-cog-6-tooth-mini"}
"shipped" ->
{"bg-purple-50", "text-purple-700", "ring-purple-600/20", "hero-truck-mini"}
"delivered" ->
{"bg-green-50", "text-green-700", "ring-green-600/20", "hero-check-circle-mini"}
"failed" ->
{"bg-red-50", "text-red-700", "ring-red-600/20", "hero-x-circle-mini"}
"cancelled" ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-no-symbol-mini"}
_ ->
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10",
"hero-minus-circle-mini"}
"submitted" -> {"blue", "hero-paper-airplane-mini"}
"processing" -> {"amber", "hero-cog-6-tooth-mini"}
"shipped" -> {"purple", "hero-truck-mini"}
"delivered" -> {"green", "hero-check-circle-mini"}
"failed" -> {"red", "hero-x-circle-mini"}
"cancelled" -> {"zinc", "hero-no-symbol-mini"}
_ -> {"zinc", "hero-minus-circle-mini"}
end
assigns = assign(assigns, bg: bg, text: text, ring: ring, icon: icon)
assigns = assign(assigns, color: color, icon: icon)
~H"""
<span class={[
"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
<span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>
<.icon name={@icon} class="size-3" /> {@status}
</span>
"""

View File

@@ -118,10 +118,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
@impl true
def render(assigns) do
~H"""
<.link
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
<.link navigate={~p"/admin/pages"} class="admin-back-link">
&larr; Pages
</.link>
@@ -153,8 +150,7 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
id="custom-page-form"
phx-change="validate"
phx-submit="save"
class="mt-6 space-y-6"
style="max-width: 32rem;"
class="admin-form-stack admin-section"
>
<.input field={@form[:title]} label="Title" />
<.input field={@form[:slug]} label="URL slug" />
@@ -167,12 +163,15 @@ defmodule BerrypodWeb.Admin.Pages.CustomForm do
<.input field={@form[:published]} type="checkbox" label="Published" />
<.input field={@form[:show_in_nav]} type="checkbox" label="Show in navigation" />
<div :if={@form[:show_in_nav].value == true} class="space-y-4 pl-6">
<div
:if={@form[:show_in_nav].value == true}
class="admin-form-sub"
>
<.input field={@form[:nav_label]} label="Nav label" />
<.input field={@form[:nav_position]} type="number" label="Nav position" />
</div>
<div class="flex gap-3">
<div class="admin-row admin-row-lg">
<.button type="submit" phx-disable-with="Saving...">
{if @live_action == :new, do: "Create page", else: "Save settings"}
</.button>

View File

@@ -521,10 +521,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
def render(assigns) do
~H"""
<div id="page-editor" phx-hook="EditorKeyboard" data-dirty={to_string(@dirty)}>
<.link
navigate={~p"/admin/pages"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
<.link navigate={~p"/admin/pages"} class="admin-back-link">
&larr; Pages
</.link>
<.header>
@@ -552,7 +549,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button>
<button
phx-click="undo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @history == [] && "opacity-30"]}
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@history == []}
aria-label="Undo"
>
@@ -560,7 +557,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button>
<button
phx-click="redo"
class={["admin-btn admin-btn-sm admin-btn-ghost", @future == [] && "opacity-30"]}
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled={@future == []}
aria-label="Redo"
>
@@ -584,7 +581,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</button>
<button
phx-click="save"
class={["admin-btn admin-btn-sm admin-btn-primary", !@dirty && "opacity-50"]}
class="admin-btn admin-btn-sm admin-btn-primary"
disabled={!@dirty}
>
Save
@@ -598,7 +595,7 @@ defmodule BerrypodWeb.Admin.Pages.Editor do
</div>
<%!-- Status badges --%>
<div class="mt-4 flex gap-2 flex-wrap">
<div class="admin-editor-badges">
<p :if={@dirty} class="admin-badge admin-badge-warning">
Unsaved changes
</p>

View File

@@ -89,14 +89,11 @@ defmodule BerrypodWeb.Admin.ProductShow do
def render(assigns) do
~H"""
<.header>
<.link
navigate={~p"/admin/products"}
class="text-sm font-normal text-base-content/60 hover:underline"
>
<.link navigate={~p"/admin/products"} class="admin-back-link">
&larr; Products
</.link>
<div class="flex items-center gap-3 mt-1">
<span class="text-2xl font-bold">{@product.title}</span>
<div class="admin-product-header">
<span class="admin-product-title">{@product.title}</span>
<.visibility_badge visible={@product.visible} />
<.status_badge status={@product.status} />
</div>
@@ -121,22 +118,26 @@ defmodule BerrypodWeb.Admin.ProductShow do
</.header>
<%!-- images + details --%>
<div class="grid gap-6 mt-6 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
<div class="admin-product-grid">
<div>
<div class="admin-product-image-grid">
<div
:for={image <- sorted_images(@product)}
class="aspect-square rounded bg-base-200 overflow-hidden"
class="admin-product-image-tile"
>
<img
src={ProductImage.url(image, 400)}
alt={image.alt || @product.title}
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
</div>
<p :if={@product.images == []} class="text-base-content/40 text-sm">No images</p>
<p
:if={@product.images == []}
class="admin-help-text"
>
No images
</p>
</div>
<div class="admin-card">
@@ -163,17 +164,19 @@ defmodule BerrypodWeb.Admin.ProductShow do
</div>
<%!-- storefront controls --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
<div class="admin-card admin-card-spaced">
<div class="admin-card-body">
<h3 class="admin-card-title">Storefront controls</h3>
<.form
for={@form}
phx-submit="save_storefront"
phx-change="validate_storefront"
class="flex flex-wrap gap-4 items-end"
class="admin-filter-row-end"
>
<label class="w-auto">
<span class="text-xs mb-0.5">Visibility</span>
<label class="admin-filter-select">
<span class="admin-filter-label">
Visibility
</span>
<select
name="product[visible]"
class="admin-select admin-select-sm"
@@ -193,8 +196,8 @@ defmodule BerrypodWeb.Admin.ProductShow do
</option>
</select>
</label>
<label class="w-auto flex-1 min-w-48">
<span class="text-xs mb-0.5">Category</span>
<label class="admin-filter-select-wide">
<span class="admin-filter-label">Category</span>
<input
type="text"
name="product[category]"
@@ -209,7 +212,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
</div>
<%!-- variants --%>
<div class="card bg-base-100 shadow-sm border border-base-200 mt-6">
<div class="admin-card admin-card-spaced">
<div class="admin-card-body">
<h3 class="admin-card-title">Variants ({length(@product.variants)})</h3>
<.table id="variants" rows={@product.variants}>
@@ -225,26 +228,25 @@ defmodule BerrypodWeb.Admin.ProductShow do
else: "—"}
</:col>
<:col :let={variant} label="Available">
<.icon
<span
:if={variant.is_enabled && variant.is_available}
name="hero-check-circle-mini"
class="size-5 text-green-600"
/>
<.icon
class="admin-icon-positive"
>
<.icon name="hero-check-circle-mini" class="size-5" />
</span>
<span
:if={!variant.is_enabled || !variant.is_available}
name="hero-x-circle-mini"
class="size-5 text-base-content/30"
/>
class="admin-icon-muted"
>
<.icon name="hero-x-circle-mini" class="size-5" />
</span>
</:col>
</.table>
</div>
</div>
<%!-- provider data --%>
<div
:if={@product.provider_connection}
class="card bg-base-100 shadow-sm border border-base-200 mt-6"
>
<div :if={@product.provider_connection} class="admin-card admin-card-spaced">
<div class="admin-card-body">
<h3 class="admin-card-title">Provider data</h3>
<.list>
@@ -255,7 +257,7 @@ defmodule BerrypodWeb.Admin.ProductShow do
<:item title="Status">{@product.status}</:item>
<:item title="Sync status">{@product.provider_connection.sync_status}</:item>
</.list>
<div class="mt-4">
<div class="admin-section-body">
<button phx-click="resync" class="admin-btn admin-btn-outline admin-btn-sm">
<.icon name="hero-arrow-path" class="size-4" /> Re-sync
</button>
@@ -274,47 +276,32 @@ defmodule BerrypodWeb.Admin.ProductShow do
end
defp visibility_badge(assigns) do
{bg, text, ring, label} =
{color, label} =
if assigns.visible do
{"bg-green-50", "text-green-700", "ring-green-600/20", "visible"}
{"green", "visible"}
else
{"bg-base-200/50", "text-base-content/60", "ring-base-content/10", "hidden"}
{"zinc", "hidden"}
end
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
assigns = assign(assigns, color: color, label: label)
~H"""
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@label}
</span>
<span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>{@label}</span>
"""
end
defp status_badge(assigns) do
{bg, text, ring} =
color =
case assigns.status do
"active" -> {"bg-green-50", "text-green-700", "ring-green-600/20"}
"draft" -> {"bg-amber-50", "text-amber-700", "ring-amber-600/20"}
"archived" -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
_ -> {"bg-base-200/50", "text-base-content/60", "ring-base-content/10"}
"active" -> "green"
"draft" -> "amber"
_ -> "zinc"
end
assigns = assign(assigns, bg: bg, text: text, ring: ring)
assigns = assign(assigns, :color, color)
~H"""
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@status}
</span>
<span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>{@status}</span>
"""
end

View File

@@ -90,7 +90,7 @@ defmodule BerrypodWeb.Admin.Products do
<:subtitle>{@product_count} products</:subtitle>
</.header>
<form phx-change="filter" class="flex gap-2 mt-6 mb-4 flex-wrap items-end">
<form phx-change="filter" class="admin-filter-row-end">
<.filter_select
:if={length(@connections) > 1}
name="provider"
@@ -142,8 +142,8 @@ defmodule BerrypodWeb.Admin.Products do
<.product_thumbnail product={product} />
</:col>
<:col :let={product} label="Product">
<div class="font-medium">
<.link navigate={~p"/admin/products/#{product}"} class="hover:underline">
<div class="admin-product-name">
<.link navigate={~p"/admin/products/#{product}"} class="admin-link">
{product.title}
</.link>
</div>
@@ -153,7 +153,7 @@ defmodule BerrypodWeb.Admin.Products do
{product.category || "—"}
</:col>
<:col :let={product} label="Price">
<span :if={product.on_sale} class="text-red-600 text-xs font-medium mr-1">Sale</span>
<span :if={product.on_sale} class="admin-sale-tag">Sale</span>
{Cart.format_price(product.cheapest_price)}
</:col>
<:col :let={product} label="Stock">
@@ -170,8 +170,7 @@ defmodule BerrypodWeb.Admin.Products do
aria-label={"Toggle visibility for #{product.title}"}
class={[
"admin-btn admin-btn-ghost admin-btn-sm",
product.visible && "text-green-600",
!product.visible && "text-base-content/30"
if(product.visible, do: "admin-icon-positive", else: "admin-icon-muted")
]}
>
<.icon :if={product.visible} name="hero-eye" class="size-5" />
@@ -182,10 +181,10 @@ defmodule BerrypodWeb.Admin.Products do
<.admin_pagination :if={@product_count > 0} page={@pagination} patch={~p"/admin/products"} />
<div :if={@product_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-cube" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No products yet</p>
<p class="text-sm mt-1">
<div :if={@product_count == 0} class="admin-empty-state">
<.icon name="hero-cube" class="admin-empty-state-icon" />
<p class="admin-empty-state-title">No products yet</p>
<p class="admin-empty-state-text">
<.link navigate={~p"/admin/providers"} class="admin-link">
Connect a provider
</.link>
@@ -201,8 +200,8 @@ defmodule BerrypodWeb.Admin.Products do
defp filter_select(assigns) do
~H"""
<label class="w-auto">
<span class="text-xs mb-0.5">{@label}</span>
<label class="admin-filter-select">
<span class="admin-filter-label">{@label}</span>
<select name={@name} class="admin-select admin-select-sm" aria-label={@label}>
<option :for={{label, value} <- @options} value={value} selected={value == @value}>
{label}
@@ -225,10 +224,13 @@ defmodule BerrypodWeb.Admin.Products do
assigns = assign(assigns, url: url, alt: alt)
~H"""
<div class="w-10 h-10 rounded bg-base-200 overflow-hidden flex-shrink-0">
<img :if={@url} src={@url} alt={@alt} class="w-full h-full object-cover" loading="lazy" />
<div :if={!@url} class="w-full h-full flex items-center justify-center">
<.icon name="hero-photo" class="size-5 text-base-content/30" />
<div class="admin-thumbnail">
<img :if={@url} src={@url} alt={@alt} loading="lazy" />
<div
:if={!@url}
class="admin-thumbnail-placeholder admin-icon-muted"
>
<.icon name="hero-photo" class="size-5" />
</div>
</div>
"""
@@ -245,31 +247,22 @@ defmodule BerrypodWeb.Admin.Products do
assigns = assign(assigns, :label, label)
~H"""
<span class="inline-flex items-center rounded-full bg-base-200 px-1.5 py-0.5 text-xs text-base-content/60 mt-0.5">
{@label}
</span>
<span class="admin-provider-badge">{@label}</span>
"""
end
defp stock_badge(assigns) do
{bg, text, ring, label} =
{color, label} =
if assigns.in_stock do
{"bg-green-50", "text-green-700", "ring-green-600/20", "In stock"}
{"green", "In stock"}
else
{"bg-red-50", "text-red-700", "ring-red-600/20", "Out of stock"}
{"red", "Out of stock"}
end
assigns = assign(assigns, bg: bg, text: text, ring: ring, label: label)
assigns = assign(assigns, color: color, label: label)
~H"""
<span class={[
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ring-1 ring-inset",
@bg,
@text,
@ring
]}>
{@label}
</span>
<span class={["admin-status-pill", "admin-status-pill-#{@color}"]}>{@label}</span>
"""
end

View File

@@ -6,7 +6,7 @@
<div class="admin-form-narrow">
<%= if @live_action == :new do %>
<p class="admin-text-secondary" style="margin-bottom: 1.5rem;">
<p class="admin-section-desc">
{@provider.name} is a print-on-demand service that prints and ships products for you.
Connect your account to automatically import your products into your shop.
</p>

View File

@@ -54,13 +54,13 @@ defmodule BerrypodWeb.Admin.Providers.Index do
defp status_indicator(assigns) do
~H"""
<span class={[
"inline-flex size-3 rounded-full",
"admin-provider-dot",
cond do
not @enabled -> "bg-base-content/30"
@status == "syncing" -> "bg-warning animate-pulse"
@status == "completed" -> "bg-success"
@status == "failed" -> "bg-error"
true -> "bg-base-content/30"
not @enabled -> "admin-provider-dot-idle"
@status == "syncing" -> "admin-provider-dot-syncing"
@status == "completed" -> "admin-provider-dot-ok"
@status == "failed" -> "admin-provider-dot-error"
true -> "admin-provider-dot-idle"
end
]} />
"""
@@ -81,7 +81,7 @@ defmodule BerrypodWeb.Admin.Providers.Index do
<.icon name="hero-clock" class="size-4 inline" />
Last synced {format_relative_time(@connection.last_synced_at)}
</span>
<span :if={!@connection.last_synced_at} class="text-warning">
<span :if={!@connection.last_synced_at} class="admin-text-warning">
<.icon name="hero-exclamation-triangle" class="size-4 inline" /> Never synced
</span>
"""

View File

@@ -14,7 +14,7 @@
</:actions>
</.header>
<div id="connections" phx-update="stream" class="admin-stack" style="margin-top: 1.5rem;">
<div id="connections" phx-update="stream" class="admin-stack admin-card-spaced">
<div id="connections-empty" class="hidden only:block">
<div class="admin-empty-state">
<.icon name="hero-cube" class="admin-empty-state-icon" />
@@ -41,9 +41,9 @@
<div class="admin-card-body">
<div class="admin-card-row">
<div class="admin-card-content">
<div class="admin-row" style="--admin-row-gap: 0.5rem;">
<div class="admin-row">
<.status_indicator status={connection.sync_status} enabled={connection.enabled} />
<h3 class="admin-card-title" style="margin-bottom: 0; font-size: 1.125rem;">
<h3 class="admin-card-title admin-section-heading admin-section-desc-flush">
{provider_name(connection.provider_type)}
</h3>
</div>
@@ -64,7 +64,7 @@
phx-click="delete"
phx-value-id={connection.id}
data-confirm={"Disconnect from #{provider_name(connection.provider_type)}? Your synced products will remain in your shop."}
class="admin-btn admin-btn-ghost admin-btn-sm text-error"
class="admin-btn admin-btn-ghost admin-btn-sm admin-text-error"
>
Disconnect
</button>

View File

@@ -199,7 +199,7 @@ defmodule BerrypodWeb.Admin.Redirects do
Redirects
</.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap">
<div class="admin-filter-row">
<.tab_button
tab="redirects"
label="Active"
@@ -261,7 +261,10 @@ defmodule BerrypodWeb.Admin.Redirects do
<td><code>{redirect.from_path}</code></td>
<td><code>{redirect.to_path}</code></td>
<td>
<span class={"badge badge-#{source_colour(redirect.source)}"}>
<span class={[
"admin-badge admin-badge-sm",
"admin-badge-#{source_colour(redirect.source)}"
]}>
{redirect.source}
</span>
</td>
@@ -315,7 +318,7 @@ defmodule BerrypodWeb.Admin.Redirects do
<td>{broken_url.recent_404_count}</td>
<td>{Calendar.strftime(broken_url.first_seen_at, "%d %b %Y")}</td>
<td>{Calendar.strftime(broken_url.last_seen_at, "%d %b %Y")}</td>
<td class="flex gap-2">
<td class="admin-table-actions-row">
<button
phx-click="redirect_broken_url"
phx-value-path={broken_url.path}
@@ -347,7 +350,7 @@ defmodule BerrypodWeb.Admin.Redirects do
defp dead_links_table(assigns) do
~H"""
<div class="flex justify-end mb-4">
<div class="admin-tab-actions">
<button phx-click="check_all_links" class="admin-btn admin-btn-sm admin-btn-ghost">
Check all
</button>
@@ -370,16 +373,19 @@ defmodule BerrypodWeb.Admin.Redirects do
</thead>
<tbody id="dead-links-table" phx-update="stream">
<tr :for={{dom_id, dead_link} <- @streams.dead_links} id={dom_id}>
<td class="max-w-xs truncate"><code>{dead_link.url}</code></td>
<td class="admin-cell-truncate truncate"><code>{dead_link.url}</code></td>
<td>
<span class={"badge badge-#{dead_link_type_colour(dead_link.url_type)}"}>
<span class={[
"admin-badge admin-badge-sm",
"admin-badge-#{dead_link_type_colour(dead_link.url_type)}"
]}>
{dead_link.url_type}
</span>
</td>
<td>{format_dead_link_error(dead_link)}</td>
<td><.dead_link_sources url={dead_link.url} /></td>
<td>{Calendar.strftime(dead_link.last_checked_at, "%d %b %Y %H:%M")}</td>
<td class="flex gap-2">
<td class="admin-table-actions-row">
<button
phx-click="recheck_dead_link"
phx-value-id={dead_link.id}
@@ -412,7 +418,7 @@ defmodule BerrypodWeb.Admin.Redirects do
defp create_form(assigns) do
~H"""
<.form for={@form} phx-submit="create_redirect" style="max-width: 32rem;">
<.form for={@form} phx-submit="create_redirect" class="admin-content-narrow">
<.input
field={@form[:from_path]}
label="From path"
@@ -453,7 +459,12 @@ defmodule BerrypodWeb.Admin.Redirects do
]}
>
{@label}
<span :if={@count && @count > 0} class="admin-badge admin-badge-sm ml-1">{@count}</span>
<span
:if={@count && @count > 0}
class="admin-badge admin-badge-sm admin-badge-count"
>
{@count}
</span>
</button>
"""
end
@@ -477,11 +488,11 @@ defmodule BerrypodWeb.Admin.Redirects do
<% [] -> %>
<span>—</span>
<% [source] -> %>
<.link navigate={source.edit_path} class="underline">{source.label}</.link>
<.link navigate={source.edit_path} class="admin-link">{source.label}</.link>
<% sources -> %>
<ul class="list-none p-0 m-0 space-y-1">
<ul class="admin-source-list">
<li :for={source <- sources}>
<.link navigate={source.edit_path} class="underline">{source.label}</.link>
<.link navigate={source.edit_path} class="admin-link">{source.label}</.link>
</li>
</ul>
<% end %>

View File

@@ -367,7 +367,7 @@ defmodule BerrypodWeb.Admin.Settings do
<.provider_connected provider={@provider} />
<% else %>
<div class="admin-section-body">
<p class="admin-section-desc" style="margin-top: 0;">
<p class="admin-section-desc admin-section-desc-flush">
Connect a print-on-demand provider to import products into your shop.
</p>
<div class="admin-section-body">
@@ -397,7 +397,7 @@ defmodule BerrypodWeb.Admin.Settings do
No tracking pixels. One email, never more.
</p>
<%= if @cart_recovery_enabled do %>
<p class="admin-section-desc" style="color: #b45309;">
<p class="admin-section-desc admin-text-warning">
Make sure your privacy policy mentions that a single recovery email may be sent,
and that customers can unsubscribe at any time.
</p>
@@ -419,7 +419,7 @@ defmodule BerrypodWeb.Admin.Settings do
<section class="admin-section">
<h2 class="admin-section-title">Account</h2>
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 1.5rem;">
<div class="admin-stack admin-stack-lg admin-section-body">
<.form
for={@email_form}
id="email_form"
@@ -433,12 +433,12 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="username"
required
/>
<div style="margin-top: 0.75rem;">
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change email</.button>
</div>
</.form>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1.5rem;">
<div class="admin-separator-xl">
<.form
for={@password_form}
id="password_form"
@@ -468,7 +468,7 @@ defmodule BerrypodWeb.Admin.Settings do
label="Confirm new password"
autocomplete="new-password"
/>
<div style="margin-top: 0.75rem;">
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Change password</.button>
</div>
</.form>
@@ -477,10 +477,10 @@ defmodule BerrypodWeb.Admin.Settings do
</section>
<%!-- Advanced --%>
<section class="admin-section" style="padding-bottom: 2.5rem;">
<section class="admin-section">
<h2 class="admin-section-title">Advanced</h2>
<div class="admin-stack" style="margin-top: 1rem; --admin-stack-gap: 0.5rem;">
<div class="admin-stack admin-stack-sm admin-section-body">
<.link href={~p"/admin/dashboard"} class="admin-link-subtle">
<.icon name="hero-chart-bar" class="size-4 inline" /> System dashboard
</.link>
@@ -548,13 +548,13 @@ defmodule BerrypodWeb.Admin.Settings do
<%= if @connection.last_synced_at do %>
{format_relative_time(@connection.last_synced_at)}
<% else %>
<span style="color: #d97706;">Never</span>
<span class="admin-text-warning">Never</span>
<% end %>
</dd>
</div>
</dl>
<div class="admin-cluster" style="margin-top: 1rem;">
<div class="admin-cluster admin-section-body">
<button
phx-click="sync"
phx-value-id={@connection.id}
@@ -578,7 +578,6 @@ defmodule BerrypodWeb.Admin.Settings do
phx-value-id={@connection.id}
data-confirm={"Disconnect from #{@provider_label}? Your synced products will remain in your shop."}
class="admin-link-danger"
style="padding: 0.375rem 0.5rem;"
>
Disconnect
</button>
@@ -590,7 +589,7 @@ defmodule BerrypodWeb.Admin.Settings do
defp stripe_setup_form(assigns) do
~H"""
<div class="admin-section-body">
<p class="admin-section-desc" style="margin-top: 0;">
<p class="admin-section-desc admin-section-desc-flush">
To accept payments, connect your Stripe account by entering your secret key.
You can find it in your
<a
@@ -604,7 +603,7 @@ defmodule BerrypodWeb.Admin.Settings do
under Developers &rarr; API keys.
</p>
<.form for={@connect_form} phx-submit="connect_stripe" style="margin-top: 1.5rem;">
<.form for={@connect_form} phx-submit="connect_stripe" class="admin-card-spaced">
<.input
field={@connect_form[:api_key]}
type="password"
@@ -612,7 +611,7 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="off"
placeholder="sk_test_... or sk_live_..."
/>
<p class="admin-text-tertiary" style="margin-top: 0.25rem;">
<p class="admin-help-text">
Starts with <code>sk_test_</code> (test mode) or <code>sk_live_</code> (live mode).
This key is encrypted at rest in the database.
</p>
@@ -628,7 +627,7 @@ defmodule BerrypodWeb.Admin.Settings do
defp stripe_connected_view(assigns) do
~H"""
<div class="admin-stack" style="margin-top: 1rem;">
<div class="admin-stack admin-section-body">
<dl class="admin-dl">
<div class="admin-dl-row">
<dt class="admin-dl-term">API key</dt>
@@ -637,7 +636,7 @@ defmodule BerrypodWeb.Admin.Settings do
<div class="admin-dl-row">
<dt class="admin-dl-term">Webhook URL</dt>
<dd class="admin-dl-value">
<code style="font-size: 0.75rem; word-break: break-all;">{@stripe_webhook_url}</code>
<code class="admin-code-break">{@stripe_webhook_url}</code>
</dd>
</div>
<div class="admin-dl-row">
@@ -646,7 +645,7 @@ defmodule BerrypodWeb.Admin.Settings do
<%= if @stripe_has_signing_secret do %>
<code>{@stripe_signing_secret_hint}</code>
<% else %>
<span style="color: #d97706;">Not set</span>
<span class="admin-text-warning">Not set</span>
<% end %>
</dd>
</div>
@@ -658,12 +657,12 @@ defmodule BerrypodWeb.Admin.Settings do
Stripe can't reach localhost for webhooks. For local testing, run the Stripe CLI:
</p>
<pre>stripe listen --forward-to localhost:4000/webhooks/stripe</pre>
<p style="margin-top: 0.5rem; font-size: 0.75rem; color: #b45309;">
<p class="admin-help-text admin-text-warning">
The CLI will output a signing secret starting with <code>whsec_</code>. Enter it below.
</p>
</div>
<.form for={@secret_form} phx-submit="save_signing_secret" style="margin-top: 0.5rem;">
<.form for={@secret_form} phx-submit="save_signing_secret">
<.input
field={@secret_form[:signing_secret]}
type="password"
@@ -671,16 +670,15 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="off"
placeholder="whsec_..."
/>
<div style="margin-top: 0.75rem;">
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save signing secret</.button>
</div>
</.form>
<% else %>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 0.75rem;">
<div class="admin-separator">
<button
phx-click="toggle_stripe_advanced"
class="admin-link-subtle admin-row"
style="--admin-row-gap: 0.25rem;"
class="admin-link-subtle admin-row admin-row-sm"
>
<.icon
name={if @advanced_open, do: "hero-chevron-down-mini", else: "hero-chevron-right-mini"}
@@ -689,8 +687,8 @@ defmodule BerrypodWeb.Admin.Settings do
</button>
<%= if @advanced_open do %>
<div style="margin-top: 0.75rem;">
<p class="admin-text-tertiary" style="margin-bottom: 0.75rem;">
<div class="admin-form-actions-sm">
<p class="admin-text-tertiary">
Override the webhook signing secret if you need to use a custom endpoint or the Stripe CLI.
</p>
<.form for={@secret_form} phx-submit="save_signing_secret">
@@ -701,7 +699,7 @@ defmodule BerrypodWeb.Admin.Settings do
autocomplete="off"
placeholder="whsec_..."
/>
<div style="margin-top: 0.75rem;">
<div class="admin-form-actions-sm">
<.button phx-disable-with="Saving...">Save signing secret</.button>
</div>
</.form>
@@ -710,7 +708,7 @@ defmodule BerrypodWeb.Admin.Settings do
</div>
<% end %>
<div style="border-top: 1px solid var(--t-surface-sunken); padding-top: 1rem;">
<div class="admin-separator-lg">
<button
phx-click="disconnect_stripe"
data-confirm="This will remove your Stripe API key and delete the webhook endpoint. Are you sure?"

View File

@@ -40,7 +40,7 @@
<!-- Header -->
<div class="theme-header">
<div style="flex: 1;">
<div class="admin-fill">
<h1 class="theme-title">Theme Studio</h1>
<p class="theme-subtitle">
One theme, infinite possibilities. Every combination is designed to work beautifully.
@@ -50,7 +50,6 @@
type="button"
phx-click="toggle_sidebar"
class="theme-collapse-btn"
style="margin: -0.25rem -0.5rem 0 0;"
aria-label="Collapse sidebar"
aria-expanded="true"
aria-controls="theme-sidebar"
@@ -69,7 +68,7 @@
</div>
<!-- Site Name -->
<div class="theme-field" style="margin-bottom: 1.5rem;">
<div class="theme-section">
<label class="theme-section-label">Shop name</label>
<form phx-change="update_setting" phx-value-field="site_name">
<input
@@ -77,18 +76,17 @@
name="site_name"
value={@theme_settings.site_name}
placeholder="Your shop name"
class="admin-input"
style="padding: 0.75rem 1rem; font-size: 1rem;"
class="admin-input admin-input-lg"
/>
</form>
</div>
<!-- Branding Section -->
<div class="theme-panel">
<label class="theme-section-label" style="margin-bottom: 1rem;">Logo & header</label>
<label class="theme-section-label">Logo & header</label>
<!-- Logo Mode Radio Cards -->
<div class="admin-stack" style="--admin-stack-gap: 0.5rem; margin-bottom: 1rem;">
<div class="admin-stack admin-stack-sm theme-field">
<%= for {value, title, desc} <- [
{"text-only", "Shop name only", "Your name in the heading font"},
{"logo-text", "Logo + shop name", "Your logo image with name beside it"},
@@ -124,7 +122,7 @@
]}>
</span>
</span>
<div style="flex: 1;">
<div class="admin-fill">
<div class="theme-radio-title">{title}</div>
<div class="admin-text-secondary">{desc}</div>
</div>
@@ -135,11 +133,11 @@
<!-- Logo Upload (for logo-text and logo-only modes) -->
<%= if @theme_settings.logo_mode in ["logo-text", "logo-only"] do %>
<div class="theme-subsection">
<span class="theme-slider-label" style="display: block; margin-bottom: 0.5rem;">
<span class="theme-slider-label theme-block-label">
Upload logo (SVG or PNG)
</span>
<div class="admin-row" style="--admin-row-gap: 0.75rem;">
<form phx-change="noop" phx-submit="noop" style="flex: 1;">
<div class="admin-row admin-row-lg">
<form phx-change="noop" phx-submit="noop" class="admin-fill">
<label class="theme-upload-label">
<span>Choose file...</span>
<.live_file_input upload={@uploads.logo_upload} class="hidden" />
@@ -163,17 +161,16 @@
<form
phx-change="update_image_alt"
phx-value-image-id={@logo_image.id}
style="margin-top: 0.5rem;"
class="theme-subfield-sm"
>
<label class="admin-row" style="--admin-row-gap: 0.5rem;">
<label class="admin-row">
<span class="admin-text-secondary shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@logo_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm"
style="flex: 1;"
class="admin-input admin-input-sm admin-fill"
phx-debounce="blur"
/>
</label>
@@ -221,7 +218,7 @@
<form
phx-change="update_setting"
phx-value-field="logo_size"
style="margin-top: 0.75rem;"
class="theme-subfield"
>
<div class="theme-slider-header">
<span class="theme-slider-label">Logo size</span>
@@ -234,13 +231,12 @@
value={@theme_settings.logo_size}
name="logo_size"
class="admin-range"
style="width: 100%;"
/>
</form>
<!-- SVG Recolor Toggle (only for SVG logos) -->
<%= if @logo_image.is_svg do %>
<div style="margin-top: 0.75rem;">
<div class="theme-subfield">
<label class="admin-toggle-label">
<input
type="checkbox"
@@ -261,8 +257,7 @@
phx-change="update_color"
phx-value-field="logo_color"
phx-hook="ColorSync"
class="theme-color-row"
style="margin-top: 0.5rem;"
class="theme-color-row theme-subfield-sm"
>
<input
type="color"
@@ -283,12 +278,12 @@
<!-- Site Icon / Favicon -->
<div class="theme-panel">
<label class="theme-section-label">Site icon</label>
<p class="admin-text-tertiary" style="margin-bottom: 1rem;">
<p class="admin-text-tertiary theme-field">
Your icon appears in browser tabs and on home screens.
</p>
<!-- Use logo as icon toggle -->
<label class="admin-toggle-label" style="margin-bottom: 1rem;">
<label class="admin-toggle-label theme-field">
<input
type="checkbox"
checked={@theme_settings.use_logo_as_icon}
@@ -301,12 +296,12 @@
<!-- Icon upload (only when not using logo) -->
<%= if !@theme_settings.use_logo_as_icon do %>
<div style="padding-top: 0.75rem; border-top: 1px solid var(--t-border-default);">
<span class="theme-slider-label" style="display: block; margin-bottom: 0.5rem;">
<div class="admin-separator">
<span class="theme-slider-label theme-block-label">
Upload icon (PNG or SVG, 512×512+)
</span>
<div class="admin-row" style="--admin-row-gap: 0.75rem;">
<form phx-change="noop" phx-submit="noop" style="flex: 1;">
<div class="admin-row admin-row-lg">
<form phx-change="noop" phx-submit="noop" class="admin-fill">
<label class="theme-upload-label">
<span>Choose file...</span>
<.live_file_input upload={@uploads.icon_upload} class="hidden" />
@@ -369,9 +364,9 @@
<% end %>
<!-- Short name -->
<div class="theme-subsection" style="padding-top: 0.75rem;">
<div class="theme-subfield">
<form phx-change="update_setting" phx-value-field="favicon_short_name">
<div class="theme-slider-header" style="margin-bottom: 0.25rem;">
<div class="theme-slider-header">
<span class="theme-slider-label">Short name</span>
<span class="admin-text-tertiary">Home screen label</span>
</div>
@@ -387,7 +382,7 @@
</div>
<!-- Icon background colour -->
<div style="margin-top: 0.75rem;">
<div class="theme-subfield">
<form
id="icon-bg-color-form"
phx-change="update_color"
@@ -402,8 +397,8 @@
class="theme-color-swatch theme-color-swatch-sm"
/>
<div>
<span class="theme-slider-label" style="display: block;">Icon background</span>
<span class="theme-slider-value" style="font-size: 0.75rem;">
<span class="theme-slider-label theme-block-label">Icon background</span>
<span class="theme-slider-value">
{@theme_settings.icon_background_color}
</span>
</div>
@@ -412,7 +407,7 @@
</div>
<!-- Header Background Toggle -->
<div style="margin-bottom: 1.5rem;">
<div class="theme-section">
<label class="admin-toggle-label">
<input
type="checkbox"
@@ -424,7 +419,7 @@
}
class="admin-toggle admin-toggle-sm"
/>
<span style="font-size: 0.875rem; color: color-mix(in oklch, var(--t-text-primary) 80%, transparent);">
<span class="theme-check-text">
Header background image
</span>
</label>
@@ -433,7 +428,7 @@
<!-- Header Image Upload (only when enabled) -->
<%= if @theme_settings.header_background_enabled do %>
<div class="theme-panel">
<span class="theme-slider-label" style="display: block; margin-bottom: 0.5rem;">
<span class="theme-slider-label theme-block-label">
Upload header image
</span>
<form phx-change="noop" phx-submit="noop">
@@ -461,17 +456,16 @@
<form
phx-change="update_image_alt"
phx-value-image-id={@header_image.id}
style="margin-top: 0.5rem;"
class="theme-subfield-sm"
>
<label class="admin-row" style="--admin-row-gap: 0.5rem;">
<label class="admin-row">
<span class="admin-text-secondary shrink-0">Alt text</span>
<input
type="text"
name="alt"
value={@header_image.alt || ""}
placeholder="Describe this image"
class="admin-input admin-input-sm"
style="flex: 1;"
class="admin-input admin-input-sm admin-fill"
phx-debounce="blur"
/>
</label>
@@ -484,7 +478,7 @@
</form>
<!-- Header Image Controls -->
<div class="admin-stack" style="--admin-stack-gap: 0.75rem; margin-top: 0.75rem;">
<div class="admin-stack admin-stack-md theme-subfield">
<form phx-change="update_setting" phx-value-field="header_zoom">
<div class="theme-slider-header">
<span class="theme-slider-label">Zoom</span>
@@ -497,7 +491,6 @@
value={@theme_settings.header_zoom}
name="header_zoom"
class="admin-range"
style="width: 100%;"
/>
</form>
<form phx-change="update_setting" phx-value-field="header_position_x">
@@ -512,7 +505,6 @@
value={@theme_settings.header_position_x}
name="header_position_x"
class="admin-range"
style="width: 100%;"
/>
</form>
<form phx-change="update_setting" phx-value-field="header_position_y">
@@ -527,7 +519,6 @@
value={@theme_settings.header_position_y}
name="header_position_y"
class="admin-range"
style="width: 100%;"
/>
</form>
</div>
@@ -565,7 +556,7 @@
<% end %>
<!-- Presets Section -->
<div style="margin-bottom: 1.5rem;">
<div class="theme-section">
<label class="theme-section-label">Start with a preset</label>
<div class="theme-presets">
<%= for {preset_name, description} <- @presets_with_descriptions do %>
@@ -586,7 +577,7 @@
</div>
<!-- Accent Colors -->
<div style="margin-bottom: 1.5rem;">
<div class="theme-section">
<label class="theme-section-label">Accent colour</label>
<form
id="accent-color-form"
@@ -607,7 +598,7 @@
</form>
</div>
<div style="margin-bottom: 1.5rem;">
<div class="theme-section">
<label class="theme-section-label">Hover colour</label>
<form
id="secondary-accent-color-form"
@@ -628,7 +619,7 @@
</form>
</div>
<div style="margin-bottom: 1.5rem;">
<div class="theme-section">
<label class="theme-section-label">Sale colour</label>
<form
id="sale-color-form"
@@ -668,7 +659,7 @@
</svg>
</summary>
<div style="padding-top: 1rem;">
<div class="theme-customise-body">
<!-- Typography Group -->
<div class="theme-group">
<div class="theme-group-header">
@@ -867,7 +858,7 @@
phx-value-field="announcement_bar"
class="admin-checkbox admin-checkbox-sm"
/>
<span style="font-size: 0.875rem;">Announcement bar</span>
<span class="theme-check-text">Announcement bar</span>
</label>
</div>
@@ -880,13 +871,13 @@
phx-value-field="sticky_header"
class="admin-checkbox admin-checkbox-sm"
/>
<span style="font-size: 0.875rem;">Sticky header</span>
<span class="theme-check-text">Sticky header</span>
</label>
</div>
</div>
<!-- Shape Group -->
<div style="margin-bottom: 1rem;">
<div class="theme-group-flush">
<div class="theme-group-header">
<svg
class="theme-group-icon"
@@ -1045,7 +1036,7 @@
phx-value-field="hover_image"
class="admin-checkbox admin-checkbox-sm"
/>
<span style="font-size: 0.875rem;">Second image on hover</span>
<span class="theme-check-text">Second image on hover</span>
</label>
</div>
@@ -1058,13 +1049,13 @@
phx-value-field="show_prices"
class="admin-checkbox admin-checkbox-sm"
/>
<span style="font-size: 0.875rem;">Show prices</span>
<span class="theme-check-text">Show prices</span>
</label>
</div>
</div>
<!-- Product Page Group -->
<div style="margin-bottom: 1rem;">
<div class="theme-group-flush">
<div class="theme-group-header">
<svg
class="theme-group-icon"
@@ -1088,7 +1079,7 @@
phx-value-field="pdp_trust_badges"
class="admin-checkbox admin-checkbox-sm"
/>
<span style="font-size: 0.875rem;">Trust badges</span>
<span class="theme-check-text">Trust badges</span>
</label>
</div>
@@ -1101,7 +1092,7 @@
phx-value-field="pdp_reviews"
class="admin-checkbox admin-checkbox-sm"
/>
<span style="font-size: 0.875rem;">Reviews section</span>
<span class="theme-check-text">Reviews section</span>
</label>
</div>
@@ -1114,7 +1105,7 @@
phx-value-field="pdp_related_products"
class="admin-checkbox admin-checkbox-sm"
/>
<span style="font-size: 0.875rem;">Related products</span>
<span class="theme-check-text">Related products</span>
</label>
</div>
</div>