berrypod/lib/berrypod_web/live/admin/newsletter.ex
jamey b7ec41b0cf refactor admin CSS: replace utility classes with semantic styles
Replace Tailwind utility soup across admin templates with semantic
CSS classes. Add layout primitives (stack, row, cluster, grid),
extract JS transition helpers into transitions.css, and refactor
core_components, layouts, settings, newsletter, order_show, providers,
and theme editor templates.

Utility occurrences reduced from 290+ to 127 across admin files.
All 1679 tests pass.

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

497 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" style="--admin-stack-gap: 1.5rem;">
<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;">
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" style="--admin-row-gap: 0.75rem;">
<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="admin-link" style="font-weight: 500;">
View subscribers
</.link>
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-link" style="font-weight: 500;">
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-row" style="justify-content: space-between; margin-bottom: 1rem;">
<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" style="margin-bottom: 1rem;">
<.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 style="display: flex; justify-content: flex-end; margin-bottom: 1rem;">
<.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 ml-1">{@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