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
<.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} />
<.overview_tab
newsletter_enabled={@newsletter_enabled}
status_counts={@status_counts}
/>
<.subscribers_tab
streams={@streams}
status_filter={@status_filter}
status_counts={@status_counts}
subscriber_count={@subscriber_count}
subscriber_pagination={@subscriber_pagination}
search={@search}
/>
<.campaigns_tab
streams={@streams}
campaign_count={@campaign_count}
campaign_pagination={@campaign_pagination}
/>
"""
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}
{@count}
"""
end
# ── Overview tab ────────────────────────────────────────────────
attr :newsletter_enabled, :boolean, required: true
attr :status_counts, :map, required: true
defp overview_tab(assigns) do
~H"""
{@status_counts["confirmed"] || 0}
Confirmed
{@status_counts["pending"] || 0}
Pending
{@status_counts["unsubscribed"] || 0}
Unsubscribed
<.link
navigate={~p"/admin/newsletter?tab=subscribers"}
class="admin-link admin-text-medium"
>
View subscribers
<.link
navigate={~p"/admin/newsletter?tab=campaigns"}
class="admin-link admin-text-medium"
>
View campaigns
"""
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"""
<.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}
/>
<.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
<.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 :let={sub} label="Status"><.subscriber_status status={sub.status} />
<:col :let={sub} label="Subscribed">{format_date(sub.inserted_at)}
<:col :let={sub} label="Source">{sub.source}
<:action :let={sub}>
<.admin_pagination
:if={@subscriber_count > 0}
page={@subscriber_pagination}
patch={~p"/admin/newsletter"}
params={%{"tab" => "subscribers"}}
/>
<.icon name="hero-envelope" class="admin-empty-state-icon" />
No subscribers yet
Subscribers will appear here when people sign up via your shop.
"""
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"""
<.link navigate={~p"/admin/newsletter/campaigns/new"} class="admin-btn admin-btn-primary">
<.icon name="hero-plus" class="size-4" /> New campaign
<.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 :let={c} label="Status"><.campaign_status status={c.status} />
<:col :let={c} label="Sent">{c.sent_count}
<:col :let={c} label="Created">{format_date(c.inserted_at)}
<:action :let={c}>
<.admin_pagination
:if={@campaign_count > 0}
page={@campaign_pagination}
patch={~p"/admin/newsletter"}
params={%{"tab" => "campaigns"}}
/>
<.icon name="hero-megaphone" class="admin-empty-state-icon" />
No campaigns yet
Create your first campaign to reach your subscribers.
"""
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"""
"""
end
attr :status, :string, required: true
defp subscriber_status(%{status: "confirmed"} = assigns) do
~H"""
Confirmed
"""
end
defp subscriber_status(%{status: "pending"} = assigns) do
~H"""
Pending
"""
end
defp subscriber_status(%{status: "unsubscribed"} = assigns) do
~H"""
Unsubscribed
"""
end
defp subscriber_status(assigns) do
~H"""
{@status}
"""
end
attr :status, :string, required: true
defp campaign_status(%{status: "draft"} = assigns) do
~H"""
Draft
"""
end
defp campaign_status(%{status: "scheduled"} = assigns) do
~H"""
Scheduled
"""
end
defp campaign_status(%{status: "sending"} = assigns) do
~H"""
Sending
"""
end
defp campaign_status(%{status: "sent"} = assigns) do
~H"""
Sent
"""
end
defp campaign_status(%{status: "cancelled"} = assigns) do
~H"""
Cancelled
"""
end
defp campaign_status(assigns) do
~H"""
{@status}
"""
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