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:
314
lib/berrypod/newsletter.ex
Normal file
314
lib/berrypod/newsletter.ex
Normal file
@@ -0,0 +1,314 @@
|
||||
defmodule Berrypod.Newsletter do
|
||||
@moduledoc """
|
||||
Newsletter subscriber management and campaign sending.
|
||||
|
||||
Privacy-first design: double opt-in, plain text only, no tracking pixels,
|
||||
hashed confirmation tokens, minimal data collection (email only).
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Newsletter.{Campaign, Subscriber}
|
||||
alias Berrypod.{Repo, Settings}
|
||||
|
||||
# ── Subscriber flow ──────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
Subscribes an email address. Creates a pending subscriber and enqueues
|
||||
a confirmation email.
|
||||
|
||||
Returns `{:ok, subscriber}` for new signups, `{:already_confirmed, subscriber}`
|
||||
if the email is already confirmed, or `{:error, changeset}` on validation failure.
|
||||
|
||||
Options:
|
||||
- `consent_text` — the exact wording shown at signup
|
||||
- `source` — "website" (default) or "admin_import"
|
||||
- `ip_hash` — hashed IP for consent proof
|
||||
"""
|
||||
def subscribe(email, opts \\ []) do
|
||||
email = email |> String.trim() |> String.downcase()
|
||||
|
||||
case Repo.get_by(Subscriber, email: email) do
|
||||
%Subscriber{status: "confirmed"} = sub ->
|
||||
{:already_confirmed, sub}
|
||||
|
||||
%Subscriber{status: "unsubscribed"} = sub ->
|
||||
# Re-subscribe: reset to pending with new token
|
||||
resubscribe(sub, opts)
|
||||
|
||||
%Subscriber{status: "pending"} = sub ->
|
||||
# Already pending — resend confirmation
|
||||
enqueue_confirmation(sub)
|
||||
{:ok, sub}
|
||||
|
||||
nil ->
|
||||
create_subscriber(email, opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp create_subscriber(email, opts) do
|
||||
{raw_token, hashed_token} = generate_token()
|
||||
|
||||
attrs = %{
|
||||
email: email,
|
||||
status: "pending",
|
||||
confirmation_token: hashed_token,
|
||||
consent_text: Keyword.get(opts, :consent_text),
|
||||
source: Keyword.get(opts, :source, "website"),
|
||||
ip_hash: Keyword.get(opts, :ip_hash)
|
||||
}
|
||||
|
||||
case %Subscriber{} |> Subscriber.changeset(attrs) |> Repo.insert() do
|
||||
{:ok, sub} ->
|
||||
enqueue_confirmation(%{sub | confirmation_token: raw_token})
|
||||
{:ok, sub}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp resubscribe(sub, opts) do
|
||||
{raw_token, hashed_token} = generate_token()
|
||||
|
||||
attrs = %{
|
||||
status: "pending",
|
||||
confirmation_token: hashed_token,
|
||||
unsubscribed_at: nil,
|
||||
consent_text: Keyword.get(opts, :consent_text) || sub.consent_text,
|
||||
ip_hash: Keyword.get(opts, :ip_hash) || sub.ip_hash
|
||||
}
|
||||
|
||||
case sub |> Subscriber.changeset(attrs) |> Repo.update() do
|
||||
{:ok, updated} ->
|
||||
enqueue_confirmation(%{updated | confirmation_token: raw_token})
|
||||
{:ok, updated}
|
||||
|
||||
{:error, changeset} ->
|
||||
{:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms a subscriber via their raw confirmation token.
|
||||
"""
|
||||
def confirm(raw_token) do
|
||||
hashed = hash_token(raw_token)
|
||||
|
||||
case Repo.get_by(Subscriber, confirmation_token: hashed) do
|
||||
%Subscriber{status: "pending"} = sub ->
|
||||
sub
|
||||
|> Subscriber.changeset(%{
|
||||
status: "confirmed",
|
||||
confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
confirmation_token: nil
|
||||
})
|
||||
|> Repo.update()
|
||||
|
||||
%Subscriber{status: "confirmed"} = sub ->
|
||||
{:ok, sub}
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_token}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Unsubscribes an email. Updates the subscriber record and adds to the
|
||||
global email suppression list.
|
||||
"""
|
||||
def unsubscribe(email) do
|
||||
email = email |> String.trim() |> String.downcase()
|
||||
|
||||
case Repo.get_by(Subscriber, email: email) do
|
||||
%Subscriber{status: "unsubscribed"} = sub ->
|
||||
{:ok, sub}
|
||||
|
||||
%Subscriber{} = sub ->
|
||||
sub
|
||||
|> Subscriber.changeset(%{
|
||||
status: "unsubscribed",
|
||||
unsubscribed_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
confirmation_token: nil
|
||||
})
|
||||
|> Repo.update()
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
# ── Subscriber queries ───────────────────────────────────────────
|
||||
|
||||
@doc "Lists subscribers, optionally filtered by status or email search."
|
||||
def list_subscribers(opts \\ []) do
|
||||
query =
|
||||
from(s in Subscriber, order_by: [desc: s.inserted_at])
|
||||
|
||||
query =
|
||||
case Keyword.get(opts, :status) do
|
||||
nil -> query
|
||||
"all" -> query
|
||||
status -> from(s in query, where: s.status == ^status)
|
||||
end
|
||||
|
||||
query =
|
||||
case Keyword.get(opts, :search) do
|
||||
nil -> query
|
||||
"" -> query
|
||||
term -> from(s in query, where: like(s.email, ^"%#{term}%"))
|
||||
end
|
||||
|
||||
Repo.all(query)
|
||||
end
|
||||
|
||||
@doc "Returns subscriber counts grouped by status."
|
||||
def count_subscribers_by_status do
|
||||
from(s in Subscriber,
|
||||
group_by: s.status,
|
||||
select: {s.status, count(s.id)}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
@doc "Returns the count of confirmed subscribers."
|
||||
def confirmed_subscriber_count do
|
||||
from(s in Subscriber, where: s.status == "confirmed", select: count())
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_subscriber!(id), do: Repo.get!(Subscriber, id)
|
||||
|
||||
@doc "Hard-deletes a subscriber (GDPR right to erasure)."
|
||||
def delete_subscriber(%Subscriber{} = sub) do
|
||||
Repo.delete(sub)
|
||||
end
|
||||
|
||||
@doc "Returns all data held about a subscriber as a plain map (GDPR right to access)."
|
||||
def export_subscriber_data(%Subscriber{} = sub) do
|
||||
%{
|
||||
email: sub.email,
|
||||
status: sub.status,
|
||||
subscribed_at: sub.inserted_at,
|
||||
confirmed_at: sub.confirmed_at,
|
||||
unsubscribed_at: sub.unsubscribed_at,
|
||||
source: sub.source,
|
||||
consent_text: sub.consent_text
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Exports all subscribers as a CSV string."
|
||||
def export_all_subscribers_csv do
|
||||
subscribers = list_subscribers()
|
||||
|
||||
header = "email,status,confirmed_at,subscribed_at\n"
|
||||
|
||||
rows =
|
||||
Enum.map_join(subscribers, "\n", fn s ->
|
||||
[
|
||||
s.email,
|
||||
s.status,
|
||||
format_csv_datetime(s.confirmed_at),
|
||||
format_csv_datetime(s.inserted_at)
|
||||
]
|
||||
|> Enum.join(",")
|
||||
end)
|
||||
|
||||
header <> rows
|
||||
end
|
||||
|
||||
defp format_csv_datetime(nil), do: ""
|
||||
defp format_csv_datetime(dt), do: Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# ── Campaign CRUD ────────────────────────────────────────────────
|
||||
|
||||
def list_campaigns do
|
||||
from(c in Campaign, order_by: [desc: c.inserted_at])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_campaign!(id), do: Repo.get!(Campaign, id)
|
||||
|
||||
def create_campaign(attrs) do
|
||||
%Campaign{}
|
||||
|> Campaign.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
def update_campaign(%Campaign{} = campaign, attrs) do
|
||||
campaign
|
||||
|> Campaign.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def delete_campaign(%Campaign{status: "draft"} = campaign) do
|
||||
Repo.delete(campaign)
|
||||
end
|
||||
|
||||
def delete_campaign(_campaign), do: {:error, :not_draft}
|
||||
|
||||
@doc "Marks a campaign as sending and enqueues the send worker."
|
||||
def send_campaign_now(%Campaign{status: status} = campaign)
|
||||
when status in ["draft", "scheduled"] do
|
||||
case update_campaign(campaign, %{status: "sending"}) do
|
||||
{:ok, updated} ->
|
||||
Berrypod.Newsletter.CampaignSendWorker.enqueue(updated.id)
|
||||
{:ok, updated}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def send_campaign_now(_), do: {:error, :invalid_status}
|
||||
|
||||
@doc "Schedules a campaign for future sending."
|
||||
def schedule_campaign(%Campaign{status: "draft"} = campaign, %DateTime{} = at) do
|
||||
update_campaign(campaign, %{status: "scheduled", scheduled_at: at})
|
||||
end
|
||||
|
||||
def schedule_campaign(_, _), do: {:error, :not_draft}
|
||||
|
||||
@doc "Cancels a scheduled campaign."
|
||||
def cancel_campaign(%Campaign{status: "scheduled"} = campaign) do
|
||||
update_campaign(campaign, %{status: "cancelled", scheduled_at: nil})
|
||||
end
|
||||
|
||||
def cancel_campaign(_), do: {:error, :not_scheduled}
|
||||
|
||||
@doc "Returns a preview of the campaign body with a sample unsubscribe URL."
|
||||
def preview_campaign(%Campaign{body: body}) do
|
||||
sample_url = BerrypodWeb.Endpoint.url() <> "/unsubscribe/sample-token"
|
||||
String.replace(body, "{{unsubscribe_url}}", sample_url)
|
||||
end
|
||||
|
||||
# ── Settings ─────────────────────────────────────────────────────
|
||||
|
||||
def newsletter_enabled? do
|
||||
Settings.get_setting("newsletter_enabled", false) == true
|
||||
end
|
||||
|
||||
def set_newsletter_enabled(enabled?) when is_boolean(enabled?) do
|
||||
Settings.put_setting("newsletter_enabled", enabled?, "boolean")
|
||||
end
|
||||
|
||||
# ── Token helpers ────────────────────────────────────────────────
|
||||
|
||||
defp generate_token do
|
||||
raw = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
|
||||
{raw, hash_token(raw)}
|
||||
end
|
||||
|
||||
defp hash_token(raw) do
|
||||
:crypto.hash(:sha256, raw) |> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
# ── Confirmation email enqueue ───────────────────────────────────
|
||||
|
||||
defp enqueue_confirmation(subscriber) do
|
||||
%{subscriber_id: subscriber.id, raw_token: subscriber.confirmation_token}
|
||||
|> Berrypod.Newsletter.ConfirmationEmailWorker.new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
end
|
||||
27
lib/berrypod/newsletter/campaign.ex
Normal file
27
lib/berrypod/newsletter/campaign.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Berrypod.Newsletter.Campaign do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "newsletter_campaigns" do
|
||||
field :subject, :string
|
||||
field :body, :string
|
||||
field :status, :string, default: "draft"
|
||||
field :scheduled_at, :utc_datetime
|
||||
field :sent_at, :utc_datetime
|
||||
field :sent_count, :integer, default: 0
|
||||
field :failed_count, :integer, default: 0
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(campaign, attrs) do
|
||||
campaign
|
||||
|> cast(attrs, [:subject, :body, :status, :scheduled_at, :sent_at, :sent_count, :failed_count])
|
||||
|> validate_required([:subject, :body])
|
||||
|> validate_length(:subject, max: 200)
|
||||
|> validate_inclusion(:status, ~w(draft scheduled sending sent cancelled))
|
||||
end
|
||||
end
|
||||
75
lib/berrypod/newsletter/campaign_send_worker.ex
Normal file
75
lib/berrypod/newsletter/campaign_send_worker.ex
Normal file
@@ -0,0 +1,75 @@
|
||||
defmodule Berrypod.Newsletter.CampaignSendWorker do
|
||||
@moduledoc """
|
||||
Sends a campaign to all confirmed, non-suppressed subscribers.
|
||||
|
||||
Processes in batches of 50 with a brief pause between batches to
|
||||
respect mail provider rate limits.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 1
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Notifier
|
||||
|
||||
require Logger
|
||||
|
||||
@batch_size 50
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"campaign_id" => id}}) do
|
||||
campaign = Newsletter.get_campaign!(id)
|
||||
|
||||
unless campaign.status in ["sending", "scheduled"] do
|
||||
Logger.info("Campaign #{id} status is #{campaign.status}, skipping")
|
||||
return_ok()
|
||||
end
|
||||
|
||||
# Ensure status is "sending"
|
||||
{:ok, campaign} =
|
||||
if campaign.status == "scheduled" do
|
||||
Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||
else
|
||||
{:ok, campaign}
|
||||
end
|
||||
|
||||
subscribers = Newsletter.list_subscribers(status: "confirmed")
|
||||
{sent, failed} = send_in_batches(campaign, subscribers)
|
||||
|
||||
Newsletter.update_campaign(campaign, %{
|
||||
status: "sent",
|
||||
sent_at: DateTime.utc_now() |> DateTime.truncate(:second),
|
||||
sent_count: sent,
|
||||
failed_count: failed
|
||||
})
|
||||
|
||||
Logger.info("Campaign #{id} sent: #{sent} delivered, #{failed} failed")
|
||||
:ok
|
||||
end
|
||||
|
||||
def enqueue(campaign_id) do
|
||||
%{campaign_id: campaign_id}
|
||||
|> new()
|
||||
|> Oban.insert()
|
||||
end
|
||||
|
||||
defp send_in_batches(campaign, subscribers) do
|
||||
subscribers
|
||||
|> Enum.chunk_every(@batch_size)
|
||||
|> Enum.reduce({0, 0}, fn batch, {sent, failed} ->
|
||||
{batch_sent, batch_failed} = send_batch(campaign, batch)
|
||||
Process.sleep(100)
|
||||
{sent + batch_sent, failed + batch_failed}
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_batch(campaign, subscribers) do
|
||||
Enum.reduce(subscribers, {0, 0}, fn sub, {sent, failed} ->
|
||||
case Notifier.deliver_campaign(campaign, sub) do
|
||||
{:ok, _} -> {sent + 1, failed}
|
||||
{:error, _} -> {sent, failed + 1}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp return_ok, do: :ok
|
||||
end
|
||||
39
lib/berrypod/newsletter/cleanup_worker.ex
Normal file
39
lib/berrypod/newsletter/cleanup_worker.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule Berrypod.Newsletter.CleanupWorker do
|
||||
@moduledoc """
|
||||
Prunes unconfirmed newsletter subscribers older than 48 hours.
|
||||
|
||||
Runs daily via cron. Pending signups that never confirmed are deleted
|
||||
to keep the subscriber list clean and respect data minimisation.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 1
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Newsletter.Subscriber
|
||||
alias Berrypod.Repo
|
||||
|
||||
require Logger
|
||||
|
||||
@hours_to_expire 48
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
cutoff =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.add(-@hours_to_expire * 3600)
|
||||
|> DateTime.truncate(:second)
|
||||
|
||||
{count, _} =
|
||||
from(s in Subscriber,
|
||||
where: s.status == "pending" and s.inserted_at < ^cutoff
|
||||
)
|
||||
|> Repo.delete_all()
|
||||
|
||||
if count > 0 do
|
||||
Logger.info("Newsletter cleanup: pruned #{count} unconfirmed subscribers")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
36
lib/berrypod/newsletter/confirmation_email_worker.ex
Normal file
36
lib/berrypod/newsletter/confirmation_email_worker.ex
Normal file
@@ -0,0 +1,36 @@
|
||||
defmodule Berrypod.Newsletter.ConfirmationEmailWorker do
|
||||
@moduledoc """
|
||||
Sends the double opt-in confirmation email to a new subscriber.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 3
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Notifier
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"subscriber_id" => id, "raw_token" => raw_token}}) do
|
||||
case Berrypod.Repo.get(Newsletter.Subscriber, id) do
|
||||
%{status: "pending"} = sub ->
|
||||
case Notifier.deliver_confirmation(sub, raw_token) do
|
||||
{:ok, _} ->
|
||||
Logger.info("Newsletter confirmation sent to #{sub.email}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Newsletter confirmation failed for #{sub.email}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
|
||||
%{status: _other} ->
|
||||
Logger.info("Newsletter confirmation skipped: subscriber #{id} no longer pending")
|
||||
:ok
|
||||
|
||||
nil ->
|
||||
Logger.warning("Newsletter confirmation: subscriber #{id} not found")
|
||||
{:cancel, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
98
lib/berrypod/newsletter/notifier.ex
Normal file
98
lib/berrypod/newsletter/notifier.ex
Normal file
@@ -0,0 +1,98 @@
|
||||
defmodule Berrypod.Newsletter.Notifier do
|
||||
@moduledoc """
|
||||
Plain text email templates for newsletter confirmation and campaigns.
|
||||
"""
|
||||
|
||||
import Swoosh.Email
|
||||
|
||||
alias Berrypod.Mailer
|
||||
|
||||
require Logger
|
||||
|
||||
@doc "Sends the double opt-in confirmation email."
|
||||
def deliver_confirmation(subscriber, raw_token) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
confirm_url = BerrypodWeb.Endpoint.url() <> "/newsletter/confirm/" <> raw_token
|
||||
|
||||
body = """
|
||||
==============================
|
||||
|
||||
Thanks for signing up to the #{shop_name} newsletter!
|
||||
|
||||
Please confirm your email to start receiving updates:
|
||||
|
||||
#{confirm_url}
|
||||
|
||||
This link expires in 48 hours. If you didn't subscribe,
|
||||
just ignore this email and you won't hear from us.
|
||||
|
||||
==============================
|
||||
"""
|
||||
|
||||
deliver(subscriber.email, "Confirm your subscription", body)
|
||||
end
|
||||
|
||||
@doc "Sends a campaign email to a single subscriber."
|
||||
def deliver_campaign(campaign, subscriber) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
unsubscribe_url = build_unsubscribe_url(subscriber.email)
|
||||
|
||||
body_with_url =
|
||||
String.replace(campaign.body, "{{unsubscribe_url}}", unsubscribe_url)
|
||||
|
||||
full_body = """
|
||||
#{body_with_url}
|
||||
|
||||
---
|
||||
You're receiving this because you subscribed to the #{shop_name} newsletter.
|
||||
Unsubscribe: #{unsubscribe_url}
|
||||
"""
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(subscriber.email)
|
||||
|> from({shop_name, from_address()})
|
||||
|> subject(campaign.subject)
|
||||
|> text_body(full_body)
|
||||
|> header("List-Unsubscribe", "<#{unsubscribe_url}>")
|
||||
|> header("List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _metadata} = result ->
|
||||
result
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Newsletter send failed for #{subscriber.email}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver(recipient, subject, body) do
|
||||
shop_name = Berrypod.Settings.get_setting("shop_name", "Berrypod")
|
||||
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({shop_name, from_address()})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
case Mailer.deliver(email) do
|
||||
{:ok, _metadata} = result ->
|
||||
result
|
||||
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Failed to send newsletter email to #{recipient}: #{inspect(reason)}")
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp from_address do
|
||||
Berrypod.Settings.get_setting("email_from_address", "noreply@example.com")
|
||||
end
|
||||
|
||||
defp build_unsubscribe_url(email) do
|
||||
token = Phoenix.Token.sign(BerrypodWeb.Endpoint, "email-unsub", email)
|
||||
BerrypodWeb.Endpoint.url() <> "/unsubscribe/" <> token
|
||||
end
|
||||
end
|
||||
36
lib/berrypod/newsletter/scheduled_campaign_worker.ex
Normal file
36
lib/berrypod/newsletter/scheduled_campaign_worker.ex
Normal file
@@ -0,0 +1,36 @@
|
||||
defmodule Berrypod.Newsletter.ScheduledCampaignWorker do
|
||||
@moduledoc """
|
||||
Picks up scheduled campaigns that are due and triggers sending.
|
||||
|
||||
Runs every 5 minutes via cron. Simpler than using Oban's schedule_in
|
||||
because it lets admins cancel scheduled campaigns by changing status.
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :newsletter, max_attempts: 1
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Berrypod.Newsletter
|
||||
alias Berrypod.Newsletter.Campaign
|
||||
alias Berrypod.Repo
|
||||
|
||||
require Logger
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||
|
||||
campaigns =
|
||||
from(c in Campaign,
|
||||
where: c.status == "scheduled" and c.scheduled_at <= ^now
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
for campaign <- campaigns do
|
||||
Logger.info("Triggering scheduled campaign #{campaign.id}: #{campaign.subject}")
|
||||
Newsletter.send_campaign_now(campaign)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
39
lib/berrypod/newsletter/subscriber.ex
Normal file
39
lib/berrypod/newsletter/subscriber.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule Berrypod.Newsletter.Subscriber do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
|
||||
schema "newsletter_subscribers" do
|
||||
field :email, :string
|
||||
field :status, :string, default: "pending"
|
||||
field :confirmation_token, :string
|
||||
field :confirmed_at, :utc_datetime
|
||||
field :unsubscribed_at, :utc_datetime
|
||||
field :consent_text, :string
|
||||
field :source, :string, default: "website"
|
||||
field :ip_hash, :string
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
def changeset(subscriber, attrs) do
|
||||
subscriber
|
||||
|> cast(attrs, [
|
||||
:email,
|
||||
:status,
|
||||
:confirmation_token,
|
||||
:confirmed_at,
|
||||
:unsubscribed_at,
|
||||
:consent_text,
|
||||
:source,
|
||||
:ip_hash
|
||||
])
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
||||
|> validate_inclusion(:status, ~w(pending confirmed unsubscribed))
|
||||
|> unique_constraint(:email)
|
||||
|> update_change(:email, &String.downcase(String.trim(&1)))
|
||||
end
|
||||
end
|
||||
@@ -110,11 +110,9 @@ defmodule Berrypod.Pages.BlockTypes do
|
||||
},
|
||||
"newsletter_card" => %{
|
||||
name: "Newsletter signup",
|
||||
description: "Email signup form — currently decorative, does not collect emails",
|
||||
description: "Email signup form with double opt-in — enable in admin settings",
|
||||
icon: "hero-envelope",
|
||||
allowed_on: :all,
|
||||
hint:
|
||||
"This block is decorative — form submissions aren't collected yet. Use it as a placeholder or remove it.",
|
||||
settings_schema: [
|
||||
%SettingsField{key: "title", label: "Title", type: :text, default: "Newsletter"},
|
||||
%SettingsField{
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
108
lib/berrypod_web/controllers/newsletter_controller.ex
Normal file
108
lib/berrypod_web/controllers/newsletter_controller.ex
Normal 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
|
||||
16
lib/berrypod_web/controllers/newsletter_export_controller.ex
Normal file
16
lib/berrypod_web/controllers/newsletter_export_controller.ex
Normal 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
|
||||
@@ -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>
|
||||
""")
|
||||
|
||||
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
|
||||
60
lib/berrypod_web/newsletter_hook.ex
Normal file
60
lib/berrypod_web/newsletter_hook.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user