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() subscribers = Newsletter.list_subscribers() campaigns = Newsletter.list_campaigns() {:ok, socket |> assign(:page_title, "Newsletter") |> assign(:tab, "overview") |> assign(:newsletter_enabled, Newsletter.newsletter_enabled?()) |> assign(:status_counts, counts) |> assign(:subscriber_count, length(subscribers)) |> assign(:campaign_count, length(campaigns)) |> assign(:status_filter, "all") |> assign(:search, "") |> stream(:subscribers, subscribers) |> stream(:campaigns, campaigns)} end @impl true def handle_params(%{"tab" => tab}, _uri, socket) when tab in ~w(overview subscribers campaigns) do socket = assign(socket, :tab, tab) socket = case tab do "subscribers" -> subscribers = Newsletter.list_subscribers( status: socket.assigns.status_filter, search: socket.assigns.search ) socket |> assign(:subscriber_count, length(subscribers)) |> stream(:subscribers, subscribers, reset: true) "campaigns" -> campaigns = Newsletter.list_campaigns() socket |> assign(:campaign_count, length(campaigns)) |> stream(:campaigns, campaigns, reset: true) _ -> socket end {:noreply, socket} end def handle_params(_params, _uri, socket), do: {:noreply, socket} @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 subscribers = Newsletter.list_subscribers(status: status, search: socket.assigns.search) {:noreply, socket |> assign(:status_filter, status) |> assign(:subscriber_count, length(subscribers)) |> stream(:subscribers, subscribers, reset: true)} end def handle_event("search_subscribers", %{"search" => term}, socket) do subscribers = Newsletter.list_subscribers(status: socket.assigns.status_filter, search: term) {:noreply, socket |> assign(:search, term) |> assign(:subscriber_count, length(subscribers)) |> stream(:subscribers, subscribers, reset: true)} 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} search={@search} />
<.campaigns_tab streams={@streams} campaign_count={@campaign_count} />
""" 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={[ "px-3 py-2 text-sm font-medium border-b-2 -mb-px", if(@tab == @active, do: "border-base-content text-base-content", else: "border-transparent text-base-content/60 hover:text-base-content hover:border-base-300" ) ]} > {@label} {@count} """ end # ── Overview tab ──────────────────────────────────────────────── attr :newsletter_enabled, :boolean, required: true attr :status_counts, :map, required: true defp overview_tab(assigns) do ~H"""

Newsletter signups

When enabled, the newsletter signup block on your shop pages will collect email addresses with double opt-in confirmation.

{@status_counts["confirmed"] || 0}

Confirmed

{@status_counts["pending"] || 0}

Pending

{@status_counts["unsubscribed"] || 0}

Unsubscribed

<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="text-sm font-medium underline"> View subscribers <.link navigate={~p"/admin/newsletter?tab=campaigns"} class="text-sm font-medium underline"> 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 :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
<.input name="search" value={@search} type="search" placeholder="Search by email..." phx-debounce="300" />
<.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}>
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" />

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 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}>
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />

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