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"""

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="admin-link" style="font-weight: 500;"> View subscribers <.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-link" style="font-weight: 500;"> 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
<.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}> <.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