add newsletter and email campaigns
Subscribers with double opt-in confirmation, campaign composer with draft/scheduled/sent lifecycle, admin dashboard with overview stats, CSV export, and shop signup form wired into page builder blocks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
501
lib/berrypod_web/live/admin/newsletter.ex
Normal file
501
lib/berrypod_web/live/admin/newsletter.ex
Normal file
@@ -0,0 +1,501 @@
|
||||
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</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="flex gap-2 mt-6 mb-6 border-b border-base-200">
|
||||
<.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}
|
||||
search={@search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :if={@tab == "campaigns"}>
|
||||
<.campaigns_tab streams={@streams} campaign_count={@campaign_count} />
|
||||
</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={[
|
||||
"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}
|
||||
<span :if={@count} class="ml-1 text-xs text-base-content/40">{@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="space-y-6">
|
||||
<div class="flex items-center gap-4 p-4 rounded-lg border border-base-200">
|
||||
<div class="flex-1">
|
||||
<h3 class="font-medium">Newsletter signups</h3>
|
||||
<p class="text-sm text-base-content/60 mt-0.5">
|
||||
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={[
|
||||
"relative inline-flex h-6 w-12 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
if(@newsletter_enabled, do: "bg-green-600", else: "bg-base-300")
|
||||
]}
|
||||
role="switch"
|
||||
aria-checked={to_string(@newsletter_enabled)}
|
||||
aria-label="Toggle newsletter signups"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform"
|
||||
style={"transform: translateX(#{if @newsletter_enabled, do: "1.5rem", else: "0"})"}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["confirmed"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Confirmed</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["pending"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Pending</p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border border-base-200 text-center">
|
||||
<p class="text-2xl font-bold">{@status_counts["unsubscribed"] || 0}</p>
|
||||
<p class="text-sm text-base-content/60">Unsubscribed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<.link navigate={~p"/admin/newsletter?tab=subscribers"} class="text-sm font-medium underline">
|
||||
View subscribers
|
||||
</.link>
|
||||
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="text-sm font-medium underline">
|
||||
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 :search, :string, required: true
|
||||
|
||||
defp subscribers_tab(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<.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="mb-4">
|
||||
<.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="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<div :if={@subscriber_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No subscribers yet</p>
|
||||
<p class="text-sm mt-1">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
|
||||
|
||||
defp campaigns_tab(assigns) do
|
||||
~H"""
|
||||
<div>
|
||||
<div class="flex justify-end mb-4">
|
||||
<.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="text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<div :if={@campaign_count == 0} class="text-center py-12 text-base-content/60">
|
||||
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />
|
||||
<p class="text-lg font-medium">No campaigns yet</p>
|
||||
<p class="text-sm mt-1">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="inline-flex items-center gap-1 text-sm text-green-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Confirmed
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(%{status: "pending"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-amber-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Pending
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(%{status: "unsubscribed"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1 text-sm text-base-content/50">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Unsubscribed
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp subscriber_status(assigns) do
|
||||
~H"""
|
||||
<span class="text-sm text-base-content/50">{@status}</span>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :status, :string, required: true
|
||||
|
||||
defp campaign_status(%{status: "draft"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-base-content/60">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-base-300"></span> Draft
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "scheduled"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-blue-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span> Scheduled
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "sending"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-amber-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span> Sending
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "sent"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-green-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500"></span> Sent
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(%{status: "cancelled"} = assigns) do
|
||||
~H"""
|
||||
<span class="inline-flex items-center gap-1.5 text-sm text-red-700">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-500"></span> Cancelled
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp campaign_status(assigns) do
|
||||
~H"""
|
||||
<span class="text-sm text-base-content/50">{@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
|
||||
236
lib/berrypod_web/live/admin/newsletter/campaign_form.ex
Normal file
236
lib/berrypod_web/live/admin/newsletter/campaign_form.ex
Normal file
@@ -0,0 +1,236 @@
|
||||
defmodule BerrypodWeb.Admin.Newsletter.CampaignForm do
|
||||
use BerrypodWeb, :live_view
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "New campaign")
|
||||
|> assign(:campaign, nil)
|
||||
|> assign(:subscriber_count, Newsletter.confirmed_subscriber_count())
|
||||
|> assign(:form, to_form(%{"subject" => "", "body" => ""}, as: :campaign))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(%{"id" => id}, _uri, socket) do
|
||||
campaign = Newsletter.get_campaign!(id)
|
||||
|
||||
title =
|
||||
if campaign.status == "draft",
|
||||
do: "Edit campaign",
|
||||
else: "Campaign: #{campaign.subject}"
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, title)
|
||||
|> assign(:campaign, campaign)
|
||||
|> assign(
|
||||
:form,
|
||||
to_form(
|
||||
%{
|
||||
"subject" => campaign.subject,
|
||||
"body" => campaign.body
|
||||
},
|
||||
as: :campaign
|
||||
)
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_params(_params, _uri, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"campaign" => params}, socket) do
|
||||
{:noreply, assign(socket, :form, to_form(params, as: :campaign))}
|
||||
end
|
||||
|
||||
def handle_event("save_draft", %{"campaign" => params}, socket) do
|
||||
case save_campaign(socket.assigns.campaign, params) do
|
||||
{:ok, campaign} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:campaign, campaign)
|
||||
|> put_flash(:info, "Campaign saved")}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Please fill in subject and body")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("send_now", _params, socket) do
|
||||
params = current_form_params(socket)
|
||||
|
||||
with {:ok, campaign} <- save_campaign(socket.assigns.campaign, params),
|
||||
{:ok, campaign} <- Newsletter.send_campaign_now(campaign) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:campaign, campaign)
|
||||
|> put_flash(:info, "Campaign is being sent!")
|
||||
|> push_navigate(to: ~p"/admin/newsletter?tab=campaigns")}
|
||||
else
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to send campaign")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("schedule", _params, socket) do
|
||||
params = current_form_params(socket)
|
||||
scheduled_at = parse_schedule_time(params["scheduled_at"])
|
||||
|
||||
with {:ok, campaign} <- save_campaign(socket.assigns.campaign, params),
|
||||
{:ok, _campaign} <- Newsletter.schedule_campaign(campaign, scheduled_at) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Campaign scheduled")
|
||||
|> push_navigate(to: ~p"/admin/newsletter?tab=campaigns")}
|
||||
else
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to schedule campaign")}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_campaign(nil, params) do
|
||||
Newsletter.create_campaign(%{
|
||||
subject: params["subject"],
|
||||
body: params["body"]
|
||||
})
|
||||
end
|
||||
|
||||
defp save_campaign(%{status: "draft"} = campaign, params) do
|
||||
Newsletter.update_campaign(campaign, %{
|
||||
subject: params["subject"],
|
||||
body: params["body"]
|
||||
})
|
||||
end
|
||||
|
||||
defp save_campaign(campaign, _params), do: {:ok, campaign}
|
||||
|
||||
defp parse_schedule_time(nil), do: DateTime.utc_now() |> DateTime.add(3600)
|
||||
defp parse_schedule_time(""), do: DateTime.utc_now() |> DateTime.add(3600)
|
||||
|
||||
defp parse_schedule_time(str) do
|
||||
case DateTime.from_iso8601(str <> ":00Z") do
|
||||
{:ok, dt, _} -> dt
|
||||
_ -> DateTime.utc_now() |> DateTime.add(3600)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
<%= cond do %>
|
||||
<% readonly?(@campaign) -> %>
|
||||
This campaign was sent on {format_date(@campaign.sent_at)} to {@campaign.sent_count} subscribers
|
||||
<% @subscriber_count > 0 -> %>
|
||||
{@subscriber_count} confirmed subscribers will receive this email
|
||||
<% true -> %>
|
||||
No confirmed subscribers yet
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="mt-6 max-w-2xl">
|
||||
<.form for={@form} phx-change="validate" phx-submit="save_draft">
|
||||
<div class="space-y-4">
|
||||
<.input
|
||||
field={@form[:subject]}
|
||||
type="text"
|
||||
label="Subject"
|
||||
required
|
||||
disabled={readonly?(@campaign)}
|
||||
placeholder="Your campaign subject"
|
||||
/>
|
||||
|
||||
<.input
|
||||
field={@form[:body]}
|
||||
type="textarea"
|
||||
label="Body (plain text)"
|
||||
required
|
||||
disabled={readonly?(@campaign)}
|
||||
rows="12"
|
||||
placeholder="Hello!\n\nYour newsletter content here.\n\nUnsubscribe: {{unsubscribe_url}}"
|
||||
/>
|
||||
|
||||
<p :if={!readonly?(@campaign)} class="text-sm text-base-content/60">
|
||||
Use <code>{"{{unsubscribe_url}}"}</code>
|
||||
to insert the unsubscribe link. This is required for GDPR compliance.
|
||||
</p>
|
||||
|
||||
<p
|
||||
:if={missing_unsubscribe_url?(@form[:body].value) && !readonly?(@campaign)}
|
||||
class="flex items-center gap-2 text-sm text-amber-700"
|
||||
>
|
||||
<.icon name="hero-exclamation-triangle" class="size-4 shrink-0" /> Body is missing
|
||||
<code>{"{{unsubscribe_url}}"}</code>
|
||||
— this is required for GDPR compliance.
|
||||
</p>
|
||||
|
||||
<%= if @form[:body].value && @form[:body].value != "" do %>
|
||||
<details class="mt-4">
|
||||
<summary class="text-sm font-medium cursor-pointer">Preview</summary>
|
||||
<pre class="mt-2 p-4 bg-base-200 rounded-lg text-sm whitespace-pre-wrap overflow-auto max-h-64">{preview_body(@form[:body].value)}</pre>
|
||||
</details>
|
||||
<% end %>
|
||||
|
||||
<div
|
||||
:if={!readonly?(@campaign)}
|
||||
class="flex items-center gap-3 pt-4 border-t border-base-200"
|
||||
>
|
||||
<.button type="submit">
|
||||
Save draft
|
||||
</.button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
phx-click="send_now"
|
||||
data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"}
|
||||
class="admin-btn admin-btn-primary"
|
||||
style="background-color: var(--color-green-600)"
|
||||
disabled={@subscriber_count == 0}
|
||||
>
|
||||
<.icon name="hero-paper-airplane" class="size-4" /> Send now
|
||||
</button>
|
||||
|
||||
<.link
|
||||
navigate={~p"/admin/newsletter?tab=campaigns"}
|
||||
class="admin-btn admin-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div :if={readonly?(@campaign)} class="pt-4 border-t border-base-200">
|
||||
<.link navigate={~p"/admin/newsletter?tab=campaigns"} class="admin-btn admin-btn-ghost">
|
||||
<.icon name="hero-arrow-left" class="size-4" /> Back to campaigns
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp current_form_params(socket) do
|
||||
form = socket.assigns.form
|
||||
%{"subject" => form[:subject].value || "", "body" => form[:body].value || ""}
|
||||
end
|
||||
|
||||
defp readonly?(%{status: status}) when status not in ["draft"], do: true
|
||||
defp readonly?(_), do: false
|
||||
|
||||
defp missing_unsubscribe_url?(nil), do: false
|
||||
defp missing_unsubscribe_url?(""), do: false
|
||||
defp missing_unsubscribe_url?(body), do: not String.contains?(body, "{{unsubscribe_url}}")
|
||||
|
||||
defp format_date(nil), do: "—"
|
||||
defp format_date(datetime), do: Calendar.strftime(datetime, "%-d %b %Y")
|
||||
|
||||
defp preview_body(body) do
|
||||
sample_url = BerrypodWeb.Endpoint.url() <> "/unsubscribe/sample-token"
|
||||
String.replace(body, "{{unsubscribe_url}}", sample_url)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user