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:
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>
|
||||
""")
|
||||
|
||||
Reference in New Issue
Block a user