- 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>
510 lines
16 KiB
Elixir
510 lines
16 KiB
Elixir
defmodule BerrypodWeb.Admin.Newsletter do
|
|
use BerrypodWeb, :live_view
|
|
|
|
alias Berrypod.Newsletter
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
counts = Newsletter.count_subscribers_by_status()
|
|
sub_page = Newsletter.list_subscribers_paginated(page: 1)
|
|
camp_page = Newsletter.list_campaigns_paginated(page: 1)
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, "Newsletter")
|
|
|> assign(:tab, "overview")
|
|
|> assign(:newsletter_enabled, Newsletter.newsletter_enabled?())
|
|
|> assign(:status_counts, counts)
|
|
|> assign(:subscriber_pagination, sub_page)
|
|
|> assign(:subscriber_count, sub_page.total_count)
|
|
|> assign(:campaign_pagination, camp_page)
|
|
|> assign(:campaign_count, camp_page.total_count)
|
|
|> assign(:status_filter, "all")
|
|
|> assign(:search, "")
|
|
|> stream(:subscribers, sub_page.items)
|
|
|> stream(:campaigns, camp_page.items)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _uri, socket) do
|
|
tab =
|
|
if params["tab"] in ~w(overview subscribers campaigns), do: params["tab"], else: "overview"
|
|
|
|
page_num = Berrypod.Pagination.parse_page(params)
|
|
|
|
socket = assign(socket, :tab, tab)
|
|
|
|
socket =
|
|
case tab do
|
|
"subscribers" ->
|
|
page =
|
|
Newsletter.list_subscribers_paginated(
|
|
status: socket.assigns.status_filter,
|
|
search: socket.assigns.search,
|
|
page: page_num
|
|
)
|
|
|
|
socket
|
|
|> assign(:subscriber_pagination, page)
|
|
|> assign(:subscriber_count, page.total_count)
|
|
|> stream(:subscribers, page.items, reset: true)
|
|
|
|
"campaigns" ->
|
|
page = Newsletter.list_campaigns_paginated(page: page_num)
|
|
|
|
socket
|
|
|> assign(:campaign_pagination, page)
|
|
|> assign(:campaign_count, page.total_count)
|
|
|> stream(:campaigns, page.items, reset: true)
|
|
|
|
_ ->
|
|
socket
|
|
end
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("toggle_enabled", _params, socket) do
|
|
new_value = !socket.assigns.newsletter_enabled
|
|
Newsletter.set_newsletter_enabled(new_value)
|
|
|
|
msg = if new_value, do: "Newsletter signups enabled", else: "Newsletter signups disabled"
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:newsletter_enabled, new_value)
|
|
|> put_flash(:info, msg)}
|
|
end
|
|
|
|
def handle_event("filter_subscribers", %{"status" => status}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:status_filter, status)
|
|
|> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
|
|
end
|
|
|
|
def handle_event("search_subscribers", %{"search" => term}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search, term)
|
|
|> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
|
|
end
|
|
|
|
def handle_event("delete_subscriber", %{"id" => id}, socket) do
|
|
sub = Newsletter.get_subscriber!(id)
|
|
{:ok, _} = Newsletter.delete_subscriber(sub)
|
|
|
|
counts = Newsletter.count_subscribers_by_status()
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:status_counts, counts)
|
|
|> assign(:subscriber_count, socket.assigns.subscriber_count - 1)
|
|
|> stream_delete(:subscribers, sub)
|
|
|> put_flash(:info, "Subscriber deleted")}
|
|
end
|
|
|
|
def handle_event("delete_campaign", %{"id" => id}, socket) do
|
|
campaign = Newsletter.get_campaign!(id)
|
|
|
|
case Newsletter.delete_campaign(campaign) do
|
|
{:ok, _} ->
|
|
{:noreply,
|
|
socket
|
|
|> assign(:campaign_count, socket.assigns.campaign_count - 1)
|
|
|> stream_delete(:campaigns, campaign)
|
|
|> put_flash(:info, "Campaign deleted")}
|
|
|
|
{:error, :not_draft} ->
|
|
{:noreply, put_flash(socket, :error, "Only draft campaigns can be deleted")}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<.header>
|
|
Newsletter
|
|
<:subtitle>Manage subscribers and email campaigns</:subtitle>
|
|
</.header>
|
|
|
|
<div class="admin-tabs">
|
|
<.tab_link label="Overview" tab="overview" active={@tab} />
|
|
<.tab_link
|
|
label="Subscribers"
|
|
tab="subscribers"
|
|
active={@tab}
|
|
count={total_subs(@status_counts)}
|
|
/>
|
|
<.tab_link label="Campaigns" tab="campaigns" active={@tab} count={@campaign_count} />
|
|
</div>
|
|
|
|
<div :if={@tab == "overview"}>
|
|
<.overview_tab
|
|
newsletter_enabled={@newsletter_enabled}
|
|
status_counts={@status_counts}
|
|
/>
|
|
</div>
|
|
|
|
<div :if={@tab == "subscribers"}>
|
|
<.subscribers_tab
|
|
streams={@streams}
|
|
status_filter={@status_filter}
|
|
status_counts={@status_counts}
|
|
subscriber_count={@subscriber_count}
|
|
subscriber_pagination={@subscriber_pagination}
|
|
search={@search}
|
|
/>
|
|
</div>
|
|
|
|
<div :if={@tab == "campaigns"}>
|
|
<.campaigns_tab
|
|
streams={@streams}
|
|
campaign_count={@campaign_count}
|
|
campaign_pagination={@campaign_pagination}
|
|
/>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Tab navigation ──────────────────────────────────────────────
|
|
|
|
attr :label, :string, required: true
|
|
attr :tab, :string, required: true
|
|
attr :active, :string, required: true
|
|
attr :count, :integer, default: nil
|
|
|
|
defp tab_link(assigns) do
|
|
~H"""
|
|
<.link
|
|
patch={~p"/admin/newsletter?tab=#{@tab}"}
|
|
class={["admin-tab", @tab == @active && "admin-tab-active"]}
|
|
>
|
|
{@label}
|
|
<span :if={@count} class="admin-tab-count">{@count}</span>
|
|
</.link>
|
|
"""
|
|
end
|
|
|
|
# ── Overview tab ────────────────────────────────────────────────
|
|
|
|
attr :newsletter_enabled, :boolean, required: true
|
|
attr :status_counts, :map, required: true
|
|
|
|
defp overview_tab(assigns) do
|
|
~H"""
|
|
<div class="admin-stack admin-stack-lg">
|
|
<div class="admin-card">
|
|
<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>
|
|
<button
|
|
phx-click="toggle_enabled"
|
|
class={[
|
|
"admin-switch",
|
|
if(@newsletter_enabled, do: "admin-switch-on", else: "admin-switch-off")
|
|
]}
|
|
role="switch"
|
|
aria-checked={to_string(@newsletter_enabled)}
|
|
aria-label="Toggle newsletter signups"
|
|
>
|
|
<span class={["admin-switch-thumb", @newsletter_enabled && "admin-switch-thumb-on"]} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-stats-grid">
|
|
<div class="admin-stat-card">
|
|
<p class="admin-stat-value">{@status_counts["confirmed"] || 0}</p>
|
|
<p class="admin-stat-label">Confirmed</p>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<p class="admin-stat-value">{@status_counts["pending"] || 0}</p>
|
|
<p class="admin-stat-label">Pending</p>
|
|
</div>
|
|
<div class="admin-stat-card">
|
|
<p class="admin-stat-value">{@status_counts["unsubscribed"] || 0}</p>
|
|
<p class="admin-stat-label">Unsubscribed</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="admin-row admin-row-lg">
|
|
<.link
|
|
navigate={~p"/admin/newsletter?tab=subscribers"}
|
|
class="admin-link admin-text-medium"
|
|
>
|
|
View subscribers
|
|
</.link>
|
|
<.link
|
|
navigate={~p"/admin/newsletter?tab=campaigns"}
|
|
class="admin-link admin-text-medium"
|
|
>
|
|
View campaigns
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Subscribers tab ─────────────────────────────────────────────
|
|
|
|
attr :streams, :any, required: true
|
|
attr :status_filter, :string, required: true
|
|
attr :status_counts, :map, required: true
|
|
attr :subscriber_count, :integer, required: true
|
|
attr :subscriber_pagination, Berrypod.Pagination, required: true
|
|
attr :search, :string, required: true
|
|
|
|
defp subscribers_tab(assigns) do
|
|
~H"""
|
|
<div>
|
|
<div class="admin-filter-row admin-filter-row-between">
|
|
<div class="admin-cluster">
|
|
<.filter_pill
|
|
status="all"
|
|
label="All"
|
|
count={total_subs(@status_counts)}
|
|
active={@status_filter}
|
|
/>
|
|
<.filter_pill
|
|
status="confirmed"
|
|
label="Confirmed"
|
|
count={@status_counts["confirmed"]}
|
|
active={@status_filter}
|
|
/>
|
|
<.filter_pill
|
|
status="pending"
|
|
label="Pending"
|
|
count={@status_counts["pending"]}
|
|
active={@status_filter}
|
|
/>
|
|
<.filter_pill
|
|
status="unsubscribed"
|
|
label="Unsubscribed"
|
|
count={@status_counts["unsubscribed"]}
|
|
active={@status_filter}
|
|
/>
|
|
</div>
|
|
<.link href={~p"/admin/newsletter/export"} class="admin-btn admin-btn-sm admin-btn-ghost">
|
|
<.icon name="hero-arrow-down-tray" class="size-4" /> Export CSV
|
|
</.link>
|
|
</div>
|
|
|
|
<form phx-change="search_subscribers" class="admin-filter-row">
|
|
<.input
|
|
name="search"
|
|
value={@search}
|
|
type="search"
|
|
placeholder="Search by email..."
|
|
phx-debounce="300"
|
|
/>
|
|
</form>
|
|
|
|
<.table
|
|
:if={@subscriber_count > 0}
|
|
id="subscribers"
|
|
rows={@streams.subscribers}
|
|
row_item={fn {_id, sub} -> sub end}
|
|
>
|
|
<:col :let={sub} label="Email">{sub.email}</:col>
|
|
<:col :let={sub} label="Status"><.subscriber_status status={sub.status} /></:col>
|
|
<:col :let={sub} label="Subscribed">{format_date(sub.inserted_at)}</:col>
|
|
<:col :let={sub} label="Source">{sub.source}</:col>
|
|
<:action :let={sub}>
|
|
<button
|
|
phx-click="delete_subscriber"
|
|
phx-value-id={sub.id}
|
|
data-confirm="Permanently delete this subscriber? This cannot be undone."
|
|
class="admin-link-danger"
|
|
>
|
|
Delete
|
|
</button>
|
|
</:action>
|
|
</.table>
|
|
|
|
<.admin_pagination
|
|
:if={@subscriber_count > 0}
|
|
page={@subscriber_pagination}
|
|
patch={~p"/admin/newsletter"}
|
|
params={%{"tab" => "subscribers"}}
|
|
/>
|
|
|
|
<div :if={@subscriber_count == 0} class="admin-empty-state">
|
|
<.icon name="hero-envelope" class="admin-empty-state-icon" />
|
|
<p class="admin-empty-state-title">No subscribers yet</p>
|
|
<p class="admin-empty-state-text">
|
|
Subscribers will appear here when people sign up via your shop.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Campaigns tab ───────────────────────────────────────────────
|
|
|
|
attr :streams, :any, required: true
|
|
attr :campaign_count, :integer, required: true
|
|
attr :campaign_pagination, Berrypod.Pagination, required: true
|
|
|
|
defp campaigns_tab(assigns) do
|
|
~H"""
|
|
<div>
|
|
<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>
|
|
</div>
|
|
|
|
<.table
|
|
:if={@campaign_count > 0}
|
|
id="campaigns"
|
|
rows={@streams.campaigns}
|
|
row_item={fn {_id, c} -> c end}
|
|
row_click={fn {_id, c} -> JS.navigate(~p"/admin/newsletter/campaigns/#{c.id}") end}
|
|
>
|
|
<:col :let={c} label="Subject">{c.subject}</:col>
|
|
<:col :let={c} label="Status"><.campaign_status status={c.status} /></:col>
|
|
<:col :let={c} label="Sent">{c.sent_count}</:col>
|
|
<:col :let={c} label="Created">{format_date(c.inserted_at)}</:col>
|
|
<:action :let={c}>
|
|
<button
|
|
:if={c.status == "draft"}
|
|
phx-click="delete_campaign"
|
|
phx-value-id={c.id}
|
|
data-confirm="Delete this draft campaign?"
|
|
class="admin-link-danger"
|
|
>
|
|
Delete
|
|
</button>
|
|
</:action>
|
|
</.table>
|
|
|
|
<.admin_pagination
|
|
:if={@campaign_count > 0}
|
|
page={@campaign_pagination}
|
|
patch={~p"/admin/newsletter"}
|
|
params={%{"tab" => "campaigns"}}
|
|
/>
|
|
|
|
<div :if={@campaign_count == 0} class="admin-empty-state">
|
|
<.icon name="hero-megaphone" class="admin-empty-state-icon" />
|
|
<p class="admin-empty-state-title">No campaigns yet</p>
|
|
<p class="admin-empty-state-text">Create your first campaign to reach your subscribers.</p>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
|
|
# ── Components ──────────────────────────────────────────────────
|
|
|
|
attr :status, :string, required: true
|
|
attr :label, :string, required: true
|
|
attr :count, :integer, default: nil
|
|
attr :active, :string, required: true
|
|
|
|
defp filter_pill(assigns) do
|
|
count = assigns[:count] || 0
|
|
active = assigns.status == assigns.active
|
|
|
|
assigns = assign(assigns, count: count, active: active)
|
|
|
|
~H"""
|
|
<button
|
|
phx-click="filter_subscribers"
|
|
phx-value-status={@status}
|
|
class={[
|
|
"admin-btn admin-btn-sm",
|
|
@active && "admin-btn-primary",
|
|
!@active && "admin-btn-ghost"
|
|
]}
|
|
>
|
|
{@label}
|
|
<span :if={@count > 0} class="admin-badge admin-badge-sm admin-badge-count">
|
|
{@count}
|
|
</span>
|
|
</button>
|
|
"""
|
|
end
|
|
|
|
attr :status, :string, required: true
|
|
|
|
defp subscriber_status(%{status: "confirmed"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-green">Confirmed</span>
|
|
"""
|
|
end
|
|
|
|
defp subscriber_status(%{status: "pending"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-amber">Pending</span>
|
|
"""
|
|
end
|
|
|
|
defp subscriber_status(%{status: "unsubscribed"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-muted">Unsubscribed</span>
|
|
"""
|
|
end
|
|
|
|
defp subscriber_status(assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-muted">{@status}</span>
|
|
"""
|
|
end
|
|
|
|
attr :status, :string, required: true
|
|
|
|
defp campaign_status(%{status: "draft"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-muted">Draft</span>
|
|
"""
|
|
end
|
|
|
|
defp campaign_status(%{status: "scheduled"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-blue">Scheduled</span>
|
|
"""
|
|
end
|
|
|
|
defp campaign_status(%{status: "sending"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-amber">Sending</span>
|
|
"""
|
|
end
|
|
|
|
defp campaign_status(%{status: "sent"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-green">Sent</span>
|
|
"""
|
|
end
|
|
|
|
defp campaign_status(%{status: "cancelled"} = assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-red">Cancelled</span>
|
|
"""
|
|
end
|
|
|
|
defp campaign_status(assigns) do
|
|
~H"""
|
|
<span class="admin-status-dot admin-status-dot-muted">{@status}</span>
|
|
"""
|
|
end
|
|
|
|
# ── Helpers ─────────────────────────────────────────────────────
|
|
|
|
defp total_subs(counts) do
|
|
Enum.reduce(counts, 0, fn {_status, count}, acc -> acc + count end)
|
|
end
|
|
|
|
defp format_date(nil), do: "—"
|
|
|
|
defp format_date(datetime) do
|
|
Calendar.strftime(datetime, "%-d %b %Y")
|
|
end
|
|
end
|