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:
jamey
2026-02-28 23:25:28 +00:00
parent 8f989d892d
commit ad2e6d1e6d
32 changed files with 2497 additions and 32 deletions

View File

@@ -126,6 +126,14 @@
<.icon name="hero-photo" class="size-5" /> Media
</.link>
</li>
<li>
<.link
navigate={~p"/admin/newsletter"}
class={admin_nav_active?(@current_path, "/admin/newsletter")}
>
<.icon name="hero-megaphone" class="size-5" /> Newsletter
</.link>
</li>
<li>
<.link
href={~p"/admin/theme"}

View File

@@ -286,16 +286,18 @@ defmodule BerrypodWeb.ShopComponents.Content do
## Attributes
* `title` - Optional. Card heading. Defaults to "Stay in touch".
* `title` - Optional. Card heading. Defaults to "Newsletter".
* `description` - Optional. Card description.
* `button_text` - Optional. Button text. Defaults to "Subscribe".
* `variant` - Optional. Either `:card` (default, with border/background) or `:inline` (no card styling, for embedding in footer).
* `newsletter_state` - Optional. `:idle | :submitted | :error | :disabled`. Defaults to `:idle`.
* `newsletter_enabled` - Optional. Whether signups are active. Defaults to `true`.
## Examples
<.newsletter_card />
<.newsletter_card title="Studio news" description="Get updates on new products." />
<.newsletter_card variant={:inline} />
<.newsletter_card variant={:inline} newsletter_state={@newsletter_state} />
"""
attr :title, :string, default: "Newsletter"
@@ -304,6 +306,30 @@ defmodule BerrypodWeb.ShopComponents.Content do
attr :button_text, :string, default: "Subscribe"
attr :variant, :atom, default: :card
attr :newsletter_state, :atom, default: :idle
attr :newsletter_enabled, :boolean, default: true
def newsletter_card(%{newsletter_state: :submitted, variant: :inline} = assigns) do
~H"""
<div>
<h3 class="newsletter-heading">{@title}</h3>
<p class="card-text card-text--spaced">
Check your inbox to confirm your subscription.
</p>
</div>
"""
end
def newsletter_card(%{newsletter_state: :submitted} = assigns) do
~H"""
<.shop_card class="card-section">
<h3 class="card-heading">{@title}</h3>
<p class="card-text card-text--spaced">
Check your inbox to confirm your subscription.
</p>
</.shop_card>
"""
end
def newsletter_card(%{variant: :inline} = assigns) do
~H"""
@@ -314,10 +340,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
<p class="card-text card-text--spaced">
{@description}
</p>
<form class="card-inline-form" onsubmit="return false">
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<%= if @newsletter_enabled do %>
<form
action="/newsletter/subscribe"
method="post"
phx-submit="newsletter_subscribe"
class="card-inline-form"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_input
type="email"
name="email"
placeholder="your@email.com"
class="email-input"
required
/>
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
Something went wrong. Please try again.
</p>
<% end %>
</div>
"""
end
@@ -329,10 +372,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
<p class="card-text card-text--spaced">
{@description}
</p>
<form class="card-inline-form" onsubmit="return false">
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<%= if @newsletter_enabled do %>
<form
action="/newsletter/subscribe"
method="post"
phx-submit="newsletter_subscribe"
class="card-inline-form"
>
<input type="hidden" name="_csrf_token" value={Phoenix.Controller.get_csrf_token()} />
<.shop_input
type="email"
name="email"
placeholder="your@email.com"
class="email-input"
required
/>
<.shop_button type="submit">{@button_text}</.shop_button>
</form>
<p :if={@newsletter_state == :error} class="card-text newsletter-error">
Something went wrong. Please try again.
</p>
<% end %>
</.shop_card>
"""
end

View File

@@ -52,7 +52,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
search_query search_results search_open categories shipping_estimate
country_code available_countries editing editor_current_path editor_sidebar_open
header_nav_items footer_nav_items)a
header_nav_items footer_nav_items newsletter_enabled newsletter_state)a
@doc """
Extracts the assigns relevant to `shop_layout` from a full assigns map.
@@ -98,6 +98,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :available_countries, :list, default: []
attr :header_nav_items, :list, default: []
attr :footer_nav_items, :list, default: []
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
slot :inner_block, required: true
@@ -136,6 +138,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
mode={@mode}
categories={assigns[:categories] || []}
footer_nav_items={@footer_nav_items}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/>
<.cart_drawer
@@ -522,6 +526,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :mode, :atom, default: :live
attr :categories, :list, default: []
attr :footer_nav_items, :list, default: []
attr :newsletter_enabled, :boolean, default: false
attr :newsletter_state, :atom, default: :idle
def shop_footer(assigns) do
assigns = assign(assigns, :current_year, Date.utc_today().year)
@@ -530,7 +536,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<footer class="shop-footer">
<div class="shop-footer-inner">
<div class="footer-grid">
<.newsletter_card variant={:inline} />
<.newsletter_card
variant={:inline}
newsletter_enabled={@newsletter_enabled}
newsletter_state={@newsletter_state}
/>
<div class="footer-links">
<div>

View File

@@ -0,0 +1,108 @@
defmodule BerrypodWeb.NewsletterController do
use BerrypodWeb, :controller
alias Berrypod.Newsletter
@doc "No-JS fallback for newsletter signup form."
def subscribe(conn, %{"email" => email}) do
ip_hash = hash_ip(conn)
case Newsletter.subscribe(email,
consent_text: "Newsletter signup on website",
ip_hash: ip_hash
) do
{:ok, _} ->
conn
|> put_flash(:info, "Check your inbox to confirm your subscription.")
|> redirect(to: redirect_back(conn))
{:already_confirmed, _} ->
conn
|> put_flash(:info, "You're already subscribed!")
|> redirect(to: redirect_back(conn))
{:error, _} ->
conn
|> put_flash(:error, "Please enter a valid email address.")
|> redirect(to: redirect_back(conn))
end
end
def subscribe(conn, _params) do
conn
|> put_flash(:error, "Please enter your email address.")
|> redirect(to: ~p"/")
end
@doc "Handles confirmation link from the double opt-in email."
def confirm(conn, %{"token" => token}) do
case Newsletter.confirm(token) do
{:ok, _sub} ->
conn
|> put_status(200)
|> html(confirmation_html(:success))
{:error, :invalid_token} ->
conn
|> put_status(400)
|> html(confirmation_html(:invalid))
end
end
defp confirmation_html(:success) do
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Subscription confirmed</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You're subscribed!</h1>
<p style="color:#555">Thanks for confirming. You'll receive the #{shop_name} newsletter from now on.</p>
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
</body>
</html>
"""
end
defp confirmation_html(:invalid) do
"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid link</title>
<style>body{font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 24px;color:#111}</style>
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">Link invalid or expired</h1>
<p style="color:#555">This confirmation link has expired or is invalid. Please try subscribing again.</p>
<p style="margin-top:1.5rem"><a href="/" style="color:#111">Back to the shop</a></p>
</body>
</html>
"""
end
defp redirect_back(conn) do
case get_req_header(conn, "referer") do
[referer | _] ->
uri = URI.parse(referer)
uri.path || "/"
_ ->
"/"
end
end
defp hash_ip(conn) do
daily_salt = Date.utc_today() |> Date.to_iso8601()
ip_string = conn.remote_ip |> :inet.ntoa() |> to_string()
:crypto.hash(:sha256, ip_string <> daily_salt) |> Base.encode16(case: :lower)
end
end

View File

@@ -0,0 +1,16 @@
defmodule BerrypodWeb.NewsletterExportController do
use BerrypodWeb, :controller
alias Berrypod.Newsletter
def export(conn, _params) do
csv = Newsletter.export_all_subscribers_csv()
today = Date.to_iso8601(Date.utc_today())
filename = "newsletter-subscribers-#{today}.csv"
conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", ~s(attachment; filename="#{filename}"))
|> send_resp(200, csv)
end
end

View File

@@ -1,7 +1,7 @@
defmodule BerrypodWeb.UnsubscribeController do
use BerrypodWeb, :controller
alias Berrypod.Orders
alias Berrypod.{Newsletter, Orders}
# Unsubscribe links should be long-lived — use 2 years
@max_age 2 * 365 * 24 * 3600
@@ -10,6 +10,7 @@ defmodule BerrypodWeb.UnsubscribeController do
case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do
{:ok, email} ->
Orders.add_suppression(email, "unsubscribed")
Newsletter.unsubscribe(email)
conn
|> put_status(200)
@@ -24,7 +25,7 @@ defmodule BerrypodWeb.UnsubscribeController do
</head>
<body>
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1>
<p style="color:#555">We've removed #{email} from our marketing list. You won't receive any more cart recovery emails from us.</p>
<p style="color:#555">We've removed #{email} from our marketing emails. You won't hear from us again.</p>
</body>
</html>
""")

View 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

View 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

View File

@@ -0,0 +1,60 @@
defmodule BerrypodWeb.NewsletterHook do
@moduledoc """
LiveView on_mount hook for newsletter signup across all shop pages.
Uses `attach_hook/4` to intercept `newsletter_subscribe` events globally
without modifying individual shop LiveViews.
"""
import Phoenix.Component, only: [assign: 3]
import Phoenix.LiveView, only: [attach_hook: 4]
alias Berrypod.Newsletter
def on_mount(:mount_newsletter, _params, _session, socket) do
enabled = Newsletter.newsletter_enabled?()
socket =
socket
|> assign(:newsletter_enabled, enabled)
|> assign(:newsletter_state, :idle)
|> assign(:newsletter_ip_hash, hash_ip(socket))
|> attach_hook(:newsletter, :handle_event, &handle_newsletter_event/3)
{:cont, socket}
end
defp handle_newsletter_event("newsletter_subscribe", %{"email" => email}, socket) do
if socket.assigns.newsletter_enabled do
case Newsletter.subscribe(email,
consent_text: "Newsletter signup on website",
ip_hash: socket.assigns.newsletter_ip_hash
) do
{:ok, _} ->
{:halt, assign(socket, :newsletter_state, :submitted)}
{:already_confirmed, _} ->
{:halt, assign(socket, :newsletter_state, :submitted)}
{:error, _} ->
{:halt, assign(socket, :newsletter_state, :error)}
end
else
{:halt, assign(socket, :newsletter_state, :disabled)}
end
end
defp handle_newsletter_event(_event, _params, socket), do: {:cont, socket}
# connect_info is only available during mount, so we hash and store it early
defp hash_ip(socket) do
case Phoenix.LiveView.get_connect_info(socket, :peer_data) do
%{address: ip} ->
daily_salt = Date.utc_today() |> Date.to_iso8601()
:crypto.hash(:sha256, :inet.ntoa(ip) ++ [daily_salt]) |> Base.encode16(case: :lower)
_ ->
nil
end
end
end

View File

@@ -310,8 +310,18 @@ defmodule BerrypodWeb.PageRenderer do
|> assign(:title, settings["title"] || "Newsletter")
|> assign(:description, settings["description"] || "")
|> assign(:button_text, settings["button_text"] || "Subscribe")
|> assign(:newsletter_state, assigns[:newsletter_state] || :idle)
|> assign(:newsletter_enabled, assigns[:newsletter_enabled] || false)
~H"<.newsletter_card title={@title} description={@description} button_text={@button_text} />"
~H"""
<.newsletter_card
title={@title}
description={@description}
button_text={@button_text}
newsletter_state={@newsletter_state}
newsletter_enabled={@newsletter_enabled}
/>
"""
end
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do

View File

@@ -136,6 +136,7 @@ defmodule BerrypodWeb.Router do
pipe_through [:browser, :require_authenticated_user, :admin]
get "/analytics/export", AnalyticsExportController, :export
get "/newsletter/export", NewsletterExportController, :export
live_session :admin,
layout: {BerrypodWeb.Layouts, :admin},
@@ -160,6 +161,9 @@ defmodule BerrypodWeb.Router do
live "/pages/:slug", Admin.Pages.Editor, :edit
live "/navigation", Admin.Navigation, :index
live "/media", Admin.Media, :index
live "/newsletter", Admin.Newsletter, :index
live "/newsletter/campaigns/new", Admin.Newsletter.CampaignForm, :new
live "/newsletter/campaigns/:id", Admin.Newsletter.CampaignForm, :edit
live "/redirects", Admin.Redirects, :index
end
@@ -203,6 +207,7 @@ defmodule BerrypodWeb.Router do
get "/orders/verify/:token", OrderLookupController, :verify
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
get "/newsletter/confirm/:token", NewsletterController, :confirm
end
# Dev-only routes (mailbox preview, error previews)
@@ -247,7 +252,8 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.CartHook, :mount_cart},
{BerrypodWeb.SearchHook, :mount_search},
{BerrypodWeb.AnalyticsHook, :track},
{BerrypodWeb.PageEditorHook, :mount_page_editor}
{BerrypodWeb.PageEditorHook, :mount_page_editor},
{BerrypodWeb.NewsletterHook, :mount_newsletter}
] do
live "/", Shop.Home, :index
live "/about", Shop.Content, :about
@@ -274,6 +280,9 @@ defmodule BerrypodWeb.Router do
post "/contact/send", ContactController, :create
post "/contact/lookup", OrderLookupController, :lookup
# Newsletter signup (no-JS fallback)
post "/newsletter/subscribe", NewsletterController, :subscribe
# Cart form actions (no-JS fallbacks for LiveView cart events)
post "/cart/add", CartController, :add
post "/cart/remove", CartController, :remove