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:
parent
8f989d892d
commit
ad2e6d1e6d
20
PROGRESS.md
20
PROGRESS.md
@ -128,16 +128,16 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
|
|||||||
| 91 | Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards | 89 | 1.5h | planned |
|
| 91 | Order timeline component on `/admin/orders/:id` — chronological feed replacing scattered field cards | 89 | 1.5h | planned |
|
||||||
| 92 | Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav | 89 | 2h | planned |
|
| 92 | Global `/admin/activity` LiveView — all activity + "needs attention" tab, resolve action, count badge on admin nav | 89 | 2h | planned |
|
||||||
| | **Admin & page editor UX polish** ([plan](docs/plans/admin-ux-polish.md)) | | | |
|
| | **Admin & page editor UX polish** ([plan](docs/plans/admin-ux-polish.md)) | | | |
|
||||||
| 103 | Unsaved changes warning — `beforeunload` + LiveView nav guard on page editor | — | 30m | planned |
|
| ~~103~~ | ~~Unsaved changes warning — `beforeunload` + LiveView nav guard on page editor~~ | — | 30m | done |
|
||||||
| 104 | Block descriptions in picker — add subtitle text to each block type | — | 45m | planned |
|
| ~~104~~ | ~~Block descriptions in picker — add subtitle text to each block type~~ | — | 45m | done |
|
||||||
| 105 | Sidebar nav grouping — section headers or nest Email/Redirects under Settings | — | 45m | planned |
|
| ~~105~~ | ~~Sidebar nav grouping — section headers (Shop/Content/Settings)~~ | — | 45m | done |
|
||||||
| 106 | Nav editor input labels — visible labels above each input pair | — | 30m | planned |
|
| ~~106~~ | ~~Nav editor input labels — visible labels above each input pair~~ | — | 30m | done |
|
||||||
| 107 | Custom page settings inline — collapsible panel in editor instead of separate page | — | 1h | planned |
|
| ~~107~~ | ~~Custom page settings inline — collapsible panel in editor~~ | — | 1h | done |
|
||||||
| 108 | Preview with real data — load actual products/categories instead of PreviewData | — | 45m | planned |
|
| ~~108~~ | ~~Preview with real data — load actual products/categories~~ | — | 45m | done |
|
||||||
| 109 | Block content preview in list — one-line summary below block name | — | 45m | planned |
|
| ~~109~~ | ~~Block content preview in list — one-line summary below block name~~ | — | 45m | done |
|
||||||
| 110 | "Providers" label clarity — rename to "Print providers" in sidebar | — | 5m | planned |
|
| ~~110~~ | ~~"Providers" label clarity — renamed to "Print providers"~~ | — | 5m | done |
|
||||||
| 111 | Newsletter block backend — wire up email collection or mark as decorative | — | 1-3h | planned |
|
| ~~111~~ | ~~Newsletter block backend — marked decorative with configurable settings~~ | — | 30m | done |
|
||||||
| 112 | Block preview thumbnails in picker — small illustrations per block type | — | 2h | planned |
|
| ~~112~~ | ~~Block preview thumbnails in picker — SVG wireframes per block type~~ | — | 2h | done |
|
||||||
| | **Other features** | | | |
|
| | **Other features** | | | |
|
||||||
| ~~72~~ | ~~Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt)~~ | — | 1.5h | done |
|
| ~~72~~ | ~~Order status lookup — wire up existing stub on contact page (UI already exists, backend unbuilt)~~ | — | 1.5h | done |
|
||||||
| | **Abandoned cart recovery** ([plan](docs/plans/abandoned-cart.md)) | | | |
|
| | **Abandoned cart recovery** ([plan](docs/plans/abandoned-cart.md)) | | | |
|
||||||
|
|||||||
@ -132,6 +132,7 @@
|
|||||||
.mt-8 { margin-top: 2rem; }
|
.mt-8 { margin-top: 2rem; }
|
||||||
.mt-10 { margin-top: 2.5rem; }
|
.mt-10 { margin-top: 2.5rem; }
|
||||||
.-mt-1 { margin-top: -0.25rem; }
|
.-mt-1 { margin-top: -0.25rem; }
|
||||||
|
.-mb-px { margin-bottom: -1px; }
|
||||||
.mb-0\.5 { margin-bottom: 0.125rem; }
|
.mb-0\.5 { margin-bottom: 0.125rem; }
|
||||||
.mb-2 { margin-bottom: 0.5rem; }
|
.mb-2 { margin-bottom: 0.5rem; }
|
||||||
.mb-3 { margin-bottom: 0.75rem; }
|
.mb-3 { margin-bottom: 0.75rem; }
|
||||||
@ -158,6 +159,7 @@
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.w-0 { width: 0; }
|
.w-0 { width: 0; }
|
||||||
|
.w-1\.5 { width: 0.375rem; }
|
||||||
.w-3 { width: 0.75rem; }
|
.w-3 { width: 0.75rem; }
|
||||||
.w-4 { width: 1rem; }
|
.w-4 { width: 1rem; }
|
||||||
.w-5 { width: 1.25rem; }
|
.w-5 { width: 1.25rem; }
|
||||||
@ -202,6 +204,7 @@
|
|||||||
.min-w-48 { min-width: 12rem; }
|
.min-w-48 { min-width: 12rem; }
|
||||||
|
|
||||||
.max-h-full { max-height: 100%; }
|
.max-h-full { max-height: 100%; }
|
||||||
|
.max-h-64 { max-height: 16rem; }
|
||||||
.max-w-80 { max-width: 20rem; }
|
.max-w-80 { max-width: 20rem; }
|
||||||
.max-w-sm { max-width: 24rem; }
|
.max-w-sm { max-width: 24rem; }
|
||||||
.max-w-md { max-width: 28rem; }
|
.max-w-md { max-width: 28rem; }
|
||||||
@ -241,6 +244,7 @@
|
|||||||
|
|
||||||
.text-wrap { text-wrap: wrap; }
|
.text-wrap { text-wrap: wrap; }
|
||||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.whitespace-pre-wrap { white-space: pre-wrap; }
|
||||||
.break-all { word-break: break-all; }
|
.break-all { word-break: break-all; }
|
||||||
.uppercase { text-transform: uppercase; }
|
.uppercase { text-transform: uppercase; }
|
||||||
.capitalize { text-transform: capitalize; }
|
.capitalize { text-transform: capitalize; }
|
||||||
@ -308,17 +312,20 @@
|
|||||||
.border-green-200 { border-color: #bbf7d0; }
|
.border-green-200 { border-color: #bbf7d0; }
|
||||||
|
|
||||||
.bg-red-50 { background-color: #fef2f2; }
|
.bg-red-50 { background-color: #fef2f2; }
|
||||||
|
.bg-red-500 { background-color: #ef4444; }
|
||||||
.text-red-600 { color: #dc2626; }
|
.text-red-600 { color: #dc2626; }
|
||||||
.text-red-700 { color: #b91c1c; }
|
.text-red-700 { color: #b91c1c; }
|
||||||
|
|
||||||
.bg-amber-50 { background-color: #fffbeb; }
|
.bg-amber-50 { background-color: #fffbeb; }
|
||||||
.bg-amber-100 { background-color: #fef3c7; }
|
.bg-amber-100 { background-color: #fef3c7; }
|
||||||
|
.bg-amber-500 { background-color: #f59e0b; }
|
||||||
.text-amber-600 { color: #d97706; }
|
.text-amber-600 { color: #d97706; }
|
||||||
.text-amber-700 { color: #b45309; }
|
.text-amber-700 { color: #b45309; }
|
||||||
.text-amber-800 { color: #92400e; }
|
.text-amber-800 { color: #92400e; }
|
||||||
.text-amber-900 { color: #78350f; }
|
.text-amber-900 { color: #78350f; }
|
||||||
|
|
||||||
.bg-blue-50 { background-color: #eff6ff; }
|
.bg-blue-50 { background-color: #eff6ff; }
|
||||||
|
.bg-blue-500 { background-color: #3b82f6; }
|
||||||
.text-blue-700 { color: #1d4ed8; }
|
.text-blue-700 { color: #1d4ed8; }
|
||||||
|
|
||||||
.bg-purple-50 { background-color: #faf5ff; }
|
.bg-purple-50 { background-color: #faf5ff; }
|
||||||
@ -342,6 +349,7 @@
|
|||||||
.border-t-0 { border-top-width: 0; }
|
.border-t-0 { border-top-width: 0; }
|
||||||
.border-dashed { border-style: dashed; }
|
.border-dashed { border-style: dashed; }
|
||||||
|
|
||||||
|
.border-transparent { border-color: transparent; }
|
||||||
.border-base-200 { border-color: var(--t-surface-sunken); }
|
.border-base-200 { border-color: var(--t-surface-sunken); }
|
||||||
.border-base-300 { border-color: var(--t-border-default); }
|
.border-base-300 { border-color: var(--t-border-default); }
|
||||||
.border-base-content\/20 { border-color: color-mix(in oklch, var(--t-text-primary) 20%, transparent); }
|
.border-base-content\/20 { border-color: color-mix(in oklch, var(--t-text-primary) 20%, transparent); }
|
||||||
@ -375,6 +383,10 @@
|
|||||||
Ring (outline via box-shadow)
|
Ring (outline via box-shadow)
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
|
.ring-0 {
|
||||||
|
--tw-ring-shadow: 0 0 #0000;
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
|
}
|
||||||
.ring-1 {
|
.ring-1 {
|
||||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 1px var(--tw-ring-color);
|
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 1px var(--tw-ring-color);
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
@ -391,6 +403,10 @@
|
|||||||
Shadow
|
Shadow
|
||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
|
.shadow {
|
||||||
|
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
|
}
|
||||||
.shadow-xs {
|
.shadow-xs {
|
||||||
--tw-shadow: 0 1px rgb(0 0 0 / 0.05);
|
--tw-shadow: 0 1px rgb(0 0 0 / 0.05);
|
||||||
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||||
@ -427,6 +443,7 @@
|
|||||||
======================================== */
|
======================================== */
|
||||||
|
|
||||||
.cursor-pointer { cursor: pointer; }
|
.cursor-pointer { cursor: pointer; }
|
||||||
|
.pointer-events-none { pointer-events: none; }
|
||||||
|
|
||||||
/* ========================================
|
/* ========================================
|
||||||
Gradient
|
Gradient
|
||||||
|
|||||||
@ -96,10 +96,12 @@ config :berrypod, Oban,
|
|||||||
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
|
{"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
|
||||||
{"0 3 * * *", Berrypod.Analytics.RetentionWorker},
|
{"0 3 * * *", Berrypod.Analytics.RetentionWorker},
|
||||||
{"0 4 * * *", Berrypod.Orders.AbandonedCartPruneWorker},
|
{"0 4 * * *", Berrypod.Orders.AbandonedCartPruneWorker},
|
||||||
{"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker}
|
{"0 5 * * 1", Berrypod.Workers.RedirectPrunerWorker},
|
||||||
|
{"0 2 * * *", Berrypod.Newsletter.CleanupWorker},
|
||||||
|
{"*/5 * * * *", Berrypod.Newsletter.ScheduledCampaignWorker}
|
||||||
]}
|
]}
|
||||||
],
|
],
|
||||||
queues: [images: 2, sync: 1, checkout: 1]
|
queues: [images: 2, sync: 1, checkout: 1, newsletter: 1]
|
||||||
|
|
||||||
# Import environment specific config. This must remain at the bottom
|
# Import environment specific config. This must remain at the bottom
|
||||||
# of this file so it overrides the configuration defined above.
|
# of this file so it overrides the configuration defined above.
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Admin & page editor UX polish
|
# Admin & page editor UX polish
|
||||||
|
|
||||||
Status: Planned
|
Status: Complete
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
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" => %{
|
"newsletter_card" => %{
|
||||||
name: "Newsletter signup",
|
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",
|
icon: "hero-envelope",
|
||||||
allowed_on: :all,
|
allowed_on: :all,
|
||||||
hint:
|
|
||||||
"This block is decorative — form submissions aren't collected yet. Use it as a placeholder or remove it.",
|
|
||||||
settings_schema: [
|
settings_schema: [
|
||||||
%SettingsField{key: "title", label: "Title", type: :text, default: "Newsletter"},
|
%SettingsField{key: "title", label: "Title", type: :text, default: "Newsletter"},
|
||||||
%SettingsField{
|
%SettingsField{
|
||||||
|
|||||||
@ -126,6 +126,14 @@
|
|||||||
<.icon name="hero-photo" class="size-5" /> Media
|
<.icon name="hero-photo" class="size-5" /> Media
|
||||||
</.link>
|
</.link>
|
||||||
</li>
|
</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>
|
<li>
|
||||||
<.link
|
<.link
|
||||||
href={~p"/admin/theme"}
|
href={~p"/admin/theme"}
|
||||||
|
|||||||
@ -286,16 +286,18 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
|||||||
|
|
||||||
## Attributes
|
## Attributes
|
||||||
|
|
||||||
* `title` - Optional. Card heading. Defaults to "Stay in touch".
|
* `title` - Optional. Card heading. Defaults to "Newsletter".
|
||||||
* `description` - Optional. Card description.
|
* `description` - Optional. Card description.
|
||||||
* `button_text` - Optional. Button text. Defaults to "Subscribe".
|
* `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).
|
* `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
|
## Examples
|
||||||
|
|
||||||
<.newsletter_card />
|
<.newsletter_card />
|
||||||
<.newsletter_card title="Studio news" description="Get updates on new products." />
|
<.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"
|
attr :title, :string, default: "Newsletter"
|
||||||
|
|
||||||
@ -304,6 +306,30 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
|||||||
|
|
||||||
attr :button_text, :string, default: "Subscribe"
|
attr :button_text, :string, default: "Subscribe"
|
||||||
attr :variant, :atom, default: :card
|
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
|
def newsletter_card(%{variant: :inline} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
@ -314,10 +340,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
|||||||
<p class="card-text card-text--spaced">
|
<p class="card-text card-text--spaced">
|
||||||
{@description}
|
{@description}
|
||||||
</p>
|
</p>
|
||||||
<form class="card-inline-form" onsubmit="return false">
|
<%= if @newsletter_enabled do %>
|
||||||
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
|
<form
|
||||||
<.shop_button type="submit">{@button_text}</.shop_button>
|
action="/newsletter/subscribe"
|
||||||
</form>
|
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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@ -329,10 +372,27 @@ defmodule BerrypodWeb.ShopComponents.Content do
|
|||||||
<p class="card-text card-text--spaced">
|
<p class="card-text card-text--spaced">
|
||||||
{@description}
|
{@description}
|
||||||
</p>
|
</p>
|
||||||
<form class="card-inline-form" onsubmit="return false">
|
<%= if @newsletter_enabled do %>
|
||||||
<.shop_input type="email" placeholder="your@email.com" class="email-input" />
|
<form
|
||||||
<.shop_button type="submit">{@button_text}</.shop_button>
|
action="/newsletter/subscribe"
|
||||||
</form>
|
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>
|
</.shop_card>
|
||||||
"""
|
"""
|
||||||
end
|
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
|
cart_subtotal cart_total cart_drawer_open cart_status active_page error_page is_admin
|
||||||
search_query search_results search_open categories shipping_estimate
|
search_query search_results search_open categories shipping_estimate
|
||||||
country_code available_countries editing editor_current_path editor_sidebar_open
|
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 """
|
@doc """
|
||||||
Extracts the assigns relevant to `shop_layout` from a full assigns map.
|
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 :available_countries, :list, default: []
|
||||||
attr :header_nav_items, :list, default: []
|
attr :header_nav_items, :list, default: []
|
||||||
attr :footer_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
|
slot :inner_block, required: true
|
||||||
|
|
||||||
@ -136,6 +138,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
mode={@mode}
|
mode={@mode}
|
||||||
categories={assigns[:categories] || []}
|
categories={assigns[:categories] || []}
|
||||||
footer_nav_items={@footer_nav_items}
|
footer_nav_items={@footer_nav_items}
|
||||||
|
newsletter_enabled={@newsletter_enabled}
|
||||||
|
newsletter_state={@newsletter_state}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<.cart_drawer
|
<.cart_drawer
|
||||||
@ -522,6 +526,8 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
attr :mode, :atom, default: :live
|
attr :mode, :atom, default: :live
|
||||||
attr :categories, :list, default: []
|
attr :categories, :list, default: []
|
||||||
attr :footer_nav_items, :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
|
def shop_footer(assigns) do
|
||||||
assigns = assign(assigns, :current_year, Date.utc_today().year)
|
assigns = assign(assigns, :current_year, Date.utc_today().year)
|
||||||
@ -530,7 +536,11 @@ defmodule BerrypodWeb.ShopComponents.Layout do
|
|||||||
<footer class="shop-footer">
|
<footer class="shop-footer">
|
||||||
<div class="shop-footer-inner">
|
<div class="shop-footer-inner">
|
||||||
<div class="footer-grid">
|
<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 class="footer-links">
|
||||||
<div>
|
<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
|
defmodule BerrypodWeb.UnsubscribeController do
|
||||||
use BerrypodWeb, :controller
|
use BerrypodWeb, :controller
|
||||||
|
|
||||||
alias Berrypod.Orders
|
alias Berrypod.{Newsletter, Orders}
|
||||||
|
|
||||||
# Unsubscribe links should be long-lived — use 2 years
|
# Unsubscribe links should be long-lived — use 2 years
|
||||||
@max_age 2 * 365 * 24 * 3600
|
@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
|
case Phoenix.Token.verify(BerrypodWeb.Endpoint, "email-unsub", token, max_age: @max_age) do
|
||||||
{:ok, email} ->
|
{:ok, email} ->
|
||||||
Orders.add_suppression(email, "unsubscribed")
|
Orders.add_suppression(email, "unsubscribed")
|
||||||
|
Newsletter.unsubscribe(email)
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_status(200)
|
|> put_status(200)
|
||||||
@ -24,7 +25,7 @@ defmodule BerrypodWeb.UnsubscribeController do
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 style="font-size:1.25rem;margin-bottom:0.75rem">You've been unsubscribed</h1>
|
<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>
|
</body>
|
||||||
</html>
|
</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(:title, settings["title"] || "Newsletter")
|
||||||
|> assign(:description, settings["description"] || "")
|
|> assign(:description, settings["description"] || "")
|
||||||
|> assign(:button_text, settings["button_text"] || "Subscribe")
|
|> 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
|
end
|
||||||
|
|
||||||
defp render_block(%{block: %{"type" => "social_links_card"}} = assigns) do
|
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]
|
pipe_through [:browser, :require_authenticated_user, :admin]
|
||||||
|
|
||||||
get "/analytics/export", AnalyticsExportController, :export
|
get "/analytics/export", AnalyticsExportController, :export
|
||||||
|
get "/newsletter/export", NewsletterExportController, :export
|
||||||
|
|
||||||
live_session :admin,
|
live_session :admin,
|
||||||
layout: {BerrypodWeb.Layouts, :admin},
|
layout: {BerrypodWeb.Layouts, :admin},
|
||||||
@ -160,6 +161,9 @@ defmodule BerrypodWeb.Router do
|
|||||||
live "/pages/:slug", Admin.Pages.Editor, :edit
|
live "/pages/:slug", Admin.Pages.Editor, :edit
|
||||||
live "/navigation", Admin.Navigation, :index
|
live "/navigation", Admin.Navigation, :index
|
||||||
live "/media", Admin.Media, :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
|
live "/redirects", Admin.Redirects, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -203,6 +207,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
|
|
||||||
get "/orders/verify/:token", OrderLookupController, :verify
|
get "/orders/verify/:token", OrderLookupController, :verify
|
||||||
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
||||||
|
get "/newsletter/confirm/:token", NewsletterController, :confirm
|
||||||
end
|
end
|
||||||
|
|
||||||
# Dev-only routes (mailbox preview, error previews)
|
# Dev-only routes (mailbox preview, error previews)
|
||||||
@ -247,7 +252,8 @@ defmodule BerrypodWeb.Router do
|
|||||||
{BerrypodWeb.CartHook, :mount_cart},
|
{BerrypodWeb.CartHook, :mount_cart},
|
||||||
{BerrypodWeb.SearchHook, :mount_search},
|
{BerrypodWeb.SearchHook, :mount_search},
|
||||||
{BerrypodWeb.AnalyticsHook, :track},
|
{BerrypodWeb.AnalyticsHook, :track},
|
||||||
{BerrypodWeb.PageEditorHook, :mount_page_editor}
|
{BerrypodWeb.PageEditorHook, :mount_page_editor},
|
||||||
|
{BerrypodWeb.NewsletterHook, :mount_newsletter}
|
||||||
] do
|
] do
|
||||||
live "/", Shop.Home, :index
|
live "/", Shop.Home, :index
|
||||||
live "/about", Shop.Content, :about
|
live "/about", Shop.Content, :about
|
||||||
@ -274,6 +280,9 @@ defmodule BerrypodWeb.Router do
|
|||||||
post "/contact/send", ContactController, :create
|
post "/contact/send", ContactController, :create
|
||||||
post "/contact/lookup", OrderLookupController, :lookup
|
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)
|
# Cart form actions (no-JS fallbacks for LiveView cart events)
|
||||||
post "/cart/add", CartController, :add
|
post "/cart/add", CartController, :add
|
||||||
post "/cart/remove", CartController, :remove
|
post "/cart/remove", CartController, :remove
|
||||||
|
|||||||
39
priv/repo/migrations/20260228204908_create_newsletter.exs
Normal file
39
priv/repo/migrations/20260228204908_create_newsletter.exs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
defmodule Berrypod.Repo.Migrations.CreateNewsletter do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:newsletter_subscribers, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :email, :string, null: false
|
||||||
|
add :status, :string, null: false, default: "pending"
|
||||||
|
add :confirmation_token, :string
|
||||||
|
add :confirmed_at, :utc_datetime
|
||||||
|
add :unsubscribed_at, :utc_datetime
|
||||||
|
add :consent_text, :string
|
||||||
|
add :source, :string, default: "website"
|
||||||
|
add :ip_hash, :string
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create unique_index(:newsletter_subscribers, [:email])
|
||||||
|
create index(:newsletter_subscribers, [:status])
|
||||||
|
create index(:newsletter_subscribers, [:confirmation_token])
|
||||||
|
|
||||||
|
create table(:newsletter_campaigns, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :subject, :string, null: false
|
||||||
|
add :body, :text, null: false
|
||||||
|
add :status, :string, null: false, default: "draft"
|
||||||
|
add :scheduled_at, :utc_datetime
|
||||||
|
add :sent_at, :utc_datetime
|
||||||
|
add :sent_count, :integer, default: 0
|
||||||
|
add :failed_count, :integer, default: 0
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
create index(:newsletter_campaigns, [:status])
|
||||||
|
create index(:newsletter_campaigns, [:scheduled_at])
|
||||||
|
end
|
||||||
|
end
|
||||||
50
test/berrypod/newsletter/campaign_send_worker_test.exs
Normal file
50
test/berrypod/newsletter/campaign_send_worker_test.exs
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
defmodule Berrypod.Newsletter.CampaignSendWorkerTest do
|
||||||
|
use Berrypod.DataCase, async: true
|
||||||
|
use Oban.Testing, repo: Berrypod.Repo
|
||||||
|
|
||||||
|
alias Berrypod.Newsletter
|
||||||
|
alias Berrypod.Newsletter.CampaignSendWorker
|
||||||
|
|
||||||
|
import Berrypod.NewsletterFixtures
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "sends campaign to confirmed subscribers" do
|
||||||
|
confirmed_subscriber_fixture(email: "a@example.com")
|
||||||
|
confirmed_subscriber_fixture(email: "b@example.com")
|
||||||
|
|
||||||
|
campaign = campaign_fixture(subject: "Hello!", body: "Test body {{unsubscribe_url}}")
|
||||||
|
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||||
|
|
||||||
|
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||||
|
|
||||||
|
campaign = Newsletter.get_campaign!(campaign.id)
|
||||||
|
assert campaign.status == "sent"
|
||||||
|
assert campaign.sent_count == 2
|
||||||
|
assert campaign.failed_count == 0
|
||||||
|
assert campaign.sent_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "only sends to confirmed subscribers" do
|
||||||
|
confirmed_subscriber_fixture(email: "active@example.com")
|
||||||
|
|
||||||
|
# Create an unsubscribed subscriber — should be skipped
|
||||||
|
sub = confirmed_subscriber_fixture(email: "unsub@example.com")
|
||||||
|
Newsletter.unsubscribe(sub.email)
|
||||||
|
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sending"})
|
||||||
|
|
||||||
|
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||||
|
|
||||||
|
campaign = Newsletter.get_campaign!(campaign.id)
|
||||||
|
assert campaign.sent_count == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips campaigns with wrong status" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
{:ok, campaign} = Newsletter.update_campaign(campaign, %{status: "sent"})
|
||||||
|
|
||||||
|
assert :ok = perform_job(CampaignSendWorker, %{campaign_id: campaign.id})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
48
test/berrypod/newsletter/cleanup_worker_test.exs
Normal file
48
test/berrypod/newsletter/cleanup_worker_test.exs
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
defmodule Berrypod.Newsletter.CleanupWorkerTest do
|
||||||
|
use Berrypod.DataCase, async: true
|
||||||
|
use Oban.Testing, repo: Berrypod.Repo
|
||||||
|
|
||||||
|
alias Berrypod.Newsletter.{CleanupWorker, Subscriber}
|
||||||
|
alias Berrypod.Repo
|
||||||
|
|
||||||
|
import Berrypod.NewsletterFixtures
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "prunes pending subscribers older than 48 hours" do
|
||||||
|
# Insert an old pending subscriber directly
|
||||||
|
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
%Subscriber{}
|
||||||
|
|> Subscriber.changeset(%{email: "old@example.com", status: "pending"})
|
||||||
|
|> Ecto.Changeset.force_change(:inserted_at, old_time)
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
# Insert a fresh pending subscriber
|
||||||
|
%Subscriber{}
|
||||||
|
|> Subscriber.changeset(%{email: "fresh@example.com", status: "pending"})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
assert :ok = perform_job(CleanupWorker, %{})
|
||||||
|
|
||||||
|
# Old pending subscriber should be deleted
|
||||||
|
assert Repo.get_by(Subscriber, email: "old@example.com") == nil
|
||||||
|
# Fresh one should remain
|
||||||
|
assert Repo.get_by(Subscriber, email: "fresh@example.com") != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not prune confirmed subscribers" do
|
||||||
|
old_time = DateTime.utc_now() |> DateTime.add(-49 * 3600) |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
confirmed = confirmed_subscriber_fixture(email: "confirmed@example.com")
|
||||||
|
|
||||||
|
# Backdate the subscriber
|
||||||
|
confirmed
|
||||||
|
|> Ecto.Changeset.change(inserted_at: old_time)
|
||||||
|
|> Repo.update!()
|
||||||
|
|
||||||
|
assert :ok = perform_job(CleanupWorker, %{})
|
||||||
|
|
||||||
|
assert Repo.get_by(Subscriber, email: "confirmed@example.com") != nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
45
test/berrypod/newsletter/confirmation_email_worker_test.exs
Normal file
45
test/berrypod/newsletter/confirmation_email_worker_test.exs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
defmodule Berrypod.Newsletter.ConfirmationEmailWorkerTest do
|
||||||
|
use Berrypod.DataCase, async: true
|
||||||
|
use Oban.Testing, repo: Berrypod.Repo
|
||||||
|
|
||||||
|
alias Berrypod.Newsletter.ConfirmationEmailWorker
|
||||||
|
|
||||||
|
import Berrypod.NewsletterFixtures
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "sends confirmation email for pending subscriber" do
|
||||||
|
# Create subscriber in manual mode so the confirmation job doesn't auto-run
|
||||||
|
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||||
|
{:ok, sub} = Berrypod.Newsletter.subscribe("test@example.com")
|
||||||
|
|
||||||
|
[job] = all_enqueued(worker: ConfirmationEmailWorker)
|
||||||
|
raw_token = job.args["raw_token"]
|
||||||
|
|
||||||
|
# Now perform the job (uses Swoosh.Adapters.Local in test)
|
||||||
|
assert :ok =
|
||||||
|
perform_job(ConfirmationEmailWorker, %{
|
||||||
|
subscriber_id: sub.id,
|
||||||
|
raw_token: raw_token
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips if subscriber is no longer pending" do
|
||||||
|
sub = confirmed_subscriber_fixture()
|
||||||
|
|
||||||
|
assert :ok =
|
||||||
|
perform_job(ConfirmationEmailWorker, %{
|
||||||
|
subscriber_id: sub.id,
|
||||||
|
raw_token: "irrelevant"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cancels if subscriber not found" do
|
||||||
|
assert {:cancel, :not_found} =
|
||||||
|
perform_job(ConfirmationEmailWorker, %{
|
||||||
|
subscriber_id: Ecto.UUID.generate(),
|
||||||
|
raw_token: "irrelevant"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
316
test/berrypod/newsletter_test.exs
Normal file
316
test/berrypod/newsletter_test.exs
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
defmodule Berrypod.NewsletterTest do
|
||||||
|
use Berrypod.DataCase, async: true
|
||||||
|
use Oban.Testing, repo: Berrypod.Repo
|
||||||
|
|
||||||
|
alias Berrypod.Newsletter
|
||||||
|
alias Berrypod.Newsletter.{Campaign, Subscriber}
|
||||||
|
|
||||||
|
import Berrypod.NewsletterFixtures
|
||||||
|
|
||||||
|
# ── Subscribe flow ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "subscribe/2" do
|
||||||
|
test "creates a pending subscriber" do
|
||||||
|
assert {:ok, %Subscriber{status: "pending"}} =
|
||||||
|
Newsletter.subscribe("test@example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "normalises email to lowercase" do
|
||||||
|
{:ok, sub} = Newsletter.subscribe("Test@EXAMPLE.com")
|
||||||
|
assert sub.email == "test@example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores consent text and source" do
|
||||||
|
{:ok, sub} =
|
||||||
|
Newsletter.subscribe("test@example.com",
|
||||||
|
consent_text: "Stay updated",
|
||||||
|
source: "website"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert sub.consent_text == "Stay updated"
|
||||||
|
assert sub.source == "website"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "stores hashed confirmation token" do
|
||||||
|
{:ok, sub} = Newsletter.subscribe("test@example.com")
|
||||||
|
sub = Repo.get!(Subscriber, sub.id)
|
||||||
|
assert sub.confirmation_token != nil
|
||||||
|
# Token is a hex-encoded SHA256 hash (64 chars)
|
||||||
|
assert byte_size(sub.confirmation_token) == 64
|
||||||
|
end
|
||||||
|
|
||||||
|
test "enqueues confirmation email worker" do
|
||||||
|
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||||
|
{:ok, _sub} = Newsletter.subscribe("test@example.com")
|
||||||
|
|
||||||
|
assert_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns already_confirmed for confirmed subscribers" do
|
||||||
|
sub = confirmed_subscriber_fixture(email: "test@example.com")
|
||||||
|
|
||||||
|
assert {:already_confirmed, ^sub} =
|
||||||
|
Newsletter.subscribe("test@example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns ok for pending subscribers (resends confirmation)" do
|
||||||
|
{:ok, sub} = Newsletter.subscribe("test@example.com")
|
||||||
|
assert {:ok, ^sub} = Newsletter.subscribe("test@example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resubscribes unsubscribed users" do
|
||||||
|
confirmed_subscriber_fixture(email: "test@example.com")
|
||||||
|
{:ok, _} = Newsletter.unsubscribe("test@example.com")
|
||||||
|
|
||||||
|
{:ok, resub} = Newsletter.subscribe("test@example.com")
|
||||||
|
assert resub.status == "pending"
|
||||||
|
assert resub.unsubscribed_at == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid email" do
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Newsletter.subscribe("not-an-email")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Confirm ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "confirm/1" do
|
||||||
|
test "confirms a pending subscriber with valid token" do
|
||||||
|
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||||
|
{:ok, _sub} = Newsletter.subscribe("test@example.com")
|
||||||
|
|
||||||
|
# Grab the raw token from the enqueued Oban job
|
||||||
|
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
|
||||||
|
raw_token = job.args["raw_token"]
|
||||||
|
|
||||||
|
assert {:ok, confirmed} = Newsletter.confirm(raw_token)
|
||||||
|
assert confirmed.status == "confirmed"
|
||||||
|
assert confirmed.confirmed_at != nil
|
||||||
|
assert confirmed.confirmation_token == nil
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid token" do
|
||||||
|
assert {:error, :invalid_token} = Newsletter.confirm("bogus-token")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is idempotent for already confirmed subscribers" do
|
||||||
|
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||||
|
{:ok, _sub} = Newsletter.subscribe("test@example.com")
|
||||||
|
[job] = all_enqueued(worker: Berrypod.Newsletter.ConfirmationEmailWorker)
|
||||||
|
raw_token = job.args["raw_token"]
|
||||||
|
|
||||||
|
{:ok, _} = Newsletter.confirm(raw_token)
|
||||||
|
# Token is cleared after first confirm, so second call with same token fails
|
||||||
|
assert {:error, :invalid_token} = Newsletter.confirm(raw_token)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Unsubscribe ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "unsubscribe/1" do
|
||||||
|
test "marks subscriber as unsubscribed" do
|
||||||
|
confirmed_subscriber_fixture(email: "test@example.com")
|
||||||
|
|
||||||
|
assert {:ok, sub} = Newsletter.unsubscribe("test@example.com")
|
||||||
|
assert sub.status == "unsubscribed"
|
||||||
|
assert sub.unsubscribed_at != nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not add to global suppression list" do
|
||||||
|
confirmed_subscriber_fixture(email: "test@example.com")
|
||||||
|
Newsletter.unsubscribe("test@example.com")
|
||||||
|
|
||||||
|
assert Berrypod.Orders.check_suppression("test@example.com") == :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is idempotent" do
|
||||||
|
confirmed_subscriber_fixture(email: "test@example.com")
|
||||||
|
{:ok, _} = Newsletter.unsubscribe("test@example.com")
|
||||||
|
assert {:ok, _} = Newsletter.unsubscribe("test@example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for unknown email" do
|
||||||
|
assert {:error, :not_found} = Newsletter.unsubscribe("unknown@example.com")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Subscriber queries ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "list_subscribers/1" do
|
||||||
|
test "returns all subscribers" do
|
||||||
|
confirmed_subscriber_fixture()
|
||||||
|
confirmed_subscriber_fixture()
|
||||||
|
|
||||||
|
result = Newsletter.list_subscribers()
|
||||||
|
assert length(result) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filters by status" do
|
||||||
|
confirmed_subscriber_fixture()
|
||||||
|
{:ok, _} = Newsletter.subscribe("pending@example.com")
|
||||||
|
|
||||||
|
result = Newsletter.list_subscribers(status: "confirmed")
|
||||||
|
assert length(result) == 1
|
||||||
|
assert hd(result).status == "confirmed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "searches by email" do
|
||||||
|
confirmed_subscriber_fixture(email: "alice@example.com")
|
||||||
|
confirmed_subscriber_fixture(email: "bob@example.com")
|
||||||
|
|
||||||
|
result = Newsletter.list_subscribers(search: "alice")
|
||||||
|
assert length(result) == 1
|
||||||
|
assert hd(result).email == "alice@example.com"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "count_subscribers_by_status/0" do
|
||||||
|
test "returns counts grouped by status" do
|
||||||
|
confirmed_subscriber_fixture()
|
||||||
|
confirmed_subscriber_fixture()
|
||||||
|
{:ok, _} = Newsletter.subscribe("pending@example.com")
|
||||||
|
|
||||||
|
counts = Newsletter.count_subscribers_by_status()
|
||||||
|
assert counts["confirmed"] == 2
|
||||||
|
assert counts["pending"] == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "confirmed_subscriber_count/0" do
|
||||||
|
test "returns count of confirmed subscribers" do
|
||||||
|
confirmed_subscriber_fixture()
|
||||||
|
confirmed_subscriber_fixture()
|
||||||
|
{:ok, _} = Newsletter.subscribe("pending@example.com")
|
||||||
|
|
||||||
|
assert Newsletter.confirmed_subscriber_count() == 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "delete_subscriber/1" do
|
||||||
|
test "hard-deletes a subscriber" do
|
||||||
|
sub = confirmed_subscriber_fixture()
|
||||||
|
assert {:ok, _} = Newsletter.delete_subscriber(sub)
|
||||||
|
assert Repo.get(Subscriber, sub.id) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "export_subscriber_data/1" do
|
||||||
|
test "returns a map of all stored data" do
|
||||||
|
sub = confirmed_subscriber_fixture(email: "export@example.com")
|
||||||
|
data = Newsletter.export_subscriber_data(sub)
|
||||||
|
|
||||||
|
assert data.email == "export@example.com"
|
||||||
|
assert data.status == "confirmed"
|
||||||
|
assert data.subscribed_at != nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "export_all_subscribers_csv/0" do
|
||||||
|
test "returns CSV with header and rows" do
|
||||||
|
confirmed_subscriber_fixture(email: "a@example.com")
|
||||||
|
confirmed_subscriber_fixture(email: "b@example.com")
|
||||||
|
|
||||||
|
csv = Newsletter.export_all_subscribers_csv()
|
||||||
|
assert csv =~ "email,status,confirmed_at,subscribed_at"
|
||||||
|
assert csv =~ "a@example.com"
|
||||||
|
assert csv =~ "b@example.com"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Campaign CRUD ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "campaign CRUD" do
|
||||||
|
test "creates a draft campaign" do
|
||||||
|
assert {:ok, %Campaign{status: "draft"}} =
|
||||||
|
Newsletter.create_campaign(%{
|
||||||
|
subject: "Test",
|
||||||
|
body: "Hello {{unsubscribe_url}}"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "lists campaigns" do
|
||||||
|
campaign_fixture(subject: "First")
|
||||||
|
campaign_fixture(subject: "Second")
|
||||||
|
|
||||||
|
result = Newsletter.list_campaigns()
|
||||||
|
assert length(result) == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "updates a campaign" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
{:ok, updated} = Newsletter.update_campaign(campaign, %{subject: "Updated"})
|
||||||
|
assert updated.subject == "Updated"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes a draft campaign" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
assert {:ok, _} = Newsletter.delete_campaign(campaign)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refuses to delete non-draft campaigns" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
|
||||||
|
assert {:error, :not_draft} = Newsletter.delete_campaign(sent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "schedule_campaign/2" do
|
||||||
|
test "schedules a draft campaign" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
assert {:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
|
||||||
|
assert scheduled.status == "scheduled"
|
||||||
|
assert scheduled.scheduled_at == at
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refuses to schedule non-draft campaigns" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
{:ok, sent} = Newsletter.update_campaign(campaign, %{status: "sent"})
|
||||||
|
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
assert {:error, :not_draft} = Newsletter.schedule_campaign(sent, at)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "cancel_campaign/1" do
|
||||||
|
test "cancels a scheduled campaign" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
at = DateTime.utc_now() |> DateTime.add(3600) |> DateTime.truncate(:second)
|
||||||
|
{:ok, scheduled} = Newsletter.schedule_campaign(campaign, at)
|
||||||
|
|
||||||
|
assert {:ok, cancelled} = Newsletter.cancel_campaign(scheduled)
|
||||||
|
assert cancelled.status == "cancelled"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "refuses to cancel non-scheduled campaigns" do
|
||||||
|
campaign = campaign_fixture()
|
||||||
|
assert {:error, :not_scheduled} = Newsletter.cancel_campaign(campaign)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "preview_campaign/1" do
|
||||||
|
test "replaces unsubscribe placeholder" do
|
||||||
|
campaign = campaign_fixture(body: "Hello! Unsub: {{unsubscribe_url}}")
|
||||||
|
preview = Newsletter.preview_campaign(campaign)
|
||||||
|
assert preview =~ "/unsubscribe/sample-token"
|
||||||
|
refute preview =~ "{{unsubscribe_url}}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Settings ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe "newsletter_enabled?/0" do
|
||||||
|
test "defaults to false" do
|
||||||
|
refute Newsletter.newsletter_enabled?()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns true when enabled" do
|
||||||
|
Newsletter.set_newsletter_enabled(true)
|
||||||
|
assert Newsletter.newsletter_enabled?()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
96
test/berrypod_web/controllers/newsletter_controller_test.exs
Normal file
96
test/berrypod_web/controllers/newsletter_controller_test.exs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
defmodule BerrypodWeb.NewsletterControllerTest do
|
||||||
|
use BerrypodWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
alias Berrypod.Newsletter
|
||||||
|
alias Berrypod.Newsletter.Subscriber
|
||||||
|
alias Berrypod.Repo
|
||||||
|
|
||||||
|
describe "POST /newsletter/subscribe" do
|
||||||
|
test "subscribes and redirects back", %{conn: conn} do
|
||||||
|
Newsletter.set_newsletter_enabled(true)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("referer", "http://localhost:4002/")
|
||||||
|
|> post(~p"/newsletter/subscribe", %{email: "new@example.com"})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Check your inbox"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handles already confirmed subscriber", %{conn: conn} do
|
||||||
|
Newsletter.set_newsletter_enabled(true)
|
||||||
|
|
||||||
|
# Create a confirmed subscriber directly
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
%Subscriber{}
|
||||||
|
|> Subscriber.changeset(%{
|
||||||
|
email: "confirmed@example.com",
|
||||||
|
status: "confirmed",
|
||||||
|
confirmed_at: now
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("referer", "http://localhost:4002/about")
|
||||||
|
|> post(~p"/newsletter/subscribe", %{email: "confirmed@example.com"})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/about"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "already subscribed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows error for invalid email", %{conn: conn} do
|
||||||
|
Newsletter.set_newsletter_enabled(true)
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("referer", "http://localhost:4002/")
|
||||||
|
|> post(~p"/newsletter/subscribe", %{email: "not-an-email"})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/"
|
||||||
|
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "valid email"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects to / when no referer", %{conn: conn} do
|
||||||
|
Newsletter.set_newsletter_enabled(true)
|
||||||
|
|
||||||
|
conn = post(conn, ~p"/newsletter/subscribe", %{email: "test@example.com"})
|
||||||
|
|
||||||
|
assert redirected_to(conn) == "/"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /newsletter/confirm/:token" do
|
||||||
|
test "confirms pending subscriber", %{conn: conn} do
|
||||||
|
# Create a pending subscriber with a known raw token
|
||||||
|
raw_token = "test-confirmation-token-abc123"
|
||||||
|
hashed_token = :crypto.hash(:sha256, raw_token) |> Base.encode16(case: :lower)
|
||||||
|
|
||||||
|
%Subscriber{}
|
||||||
|
|> Subscriber.changeset(%{
|
||||||
|
email: "pending@example.com",
|
||||||
|
status: "pending",
|
||||||
|
confirmation_token: hashed_token
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
conn = get(conn, "/newsletter/confirm/#{raw_token}")
|
||||||
|
|
||||||
|
assert conn.status == 200
|
||||||
|
assert conn.resp_body =~ "subscribed"
|
||||||
|
|
||||||
|
# Verify the subscriber is now confirmed
|
||||||
|
sub = Repo.get_by!(Subscriber, email: "pending@example.com")
|
||||||
|
assert sub.status == "confirmed"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid token", %{conn: conn} do
|
||||||
|
conn = get(conn, "/newsletter/confirm/invalid-token-here")
|
||||||
|
|
||||||
|
assert conn.status == 400
|
||||||
|
assert conn.resp_body =~ "invalid"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
131
test/berrypod_web/live/admin/newsletter_test.exs
Normal file
131
test/berrypod_web/live/admin/newsletter_test.exs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
defmodule BerrypodWeb.Admin.NewsletterTest do
|
||||||
|
use BerrypodWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import Berrypod.AccountsFixtures
|
||||||
|
import Berrypod.NewsletterFixtures
|
||||||
|
|
||||||
|
alias Berrypod.Newsletter
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
user = user_fixture()
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
%{conn: conn, user: user}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "overview tab" do
|
||||||
|
test "shows newsletter toggle", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
|
||||||
|
|
||||||
|
assert has_element?(view, "[role=switch]")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggles newsletter enabled", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter")
|
||||||
|
|
||||||
|
refute Newsletter.newsletter_enabled?()
|
||||||
|
|
||||||
|
view |> element("[role=switch]") |> render_click()
|
||||||
|
|
||||||
|
assert Newsletter.newsletter_enabled?()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows subscriber counts", %{conn: conn} do
|
||||||
|
confirmed_subscriber_fixture(email: "a@example.com")
|
||||||
|
confirmed_subscriber_fixture(email: "b@example.com")
|
||||||
|
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/admin/newsletter")
|
||||||
|
|
||||||
|
assert html =~ "2"
|
||||||
|
assert html =~ "Confirmed"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "subscribers tab" do
|
||||||
|
test "lists subscribers", %{conn: conn} do
|
||||||
|
confirmed_subscriber_fixture(email: "listed@example.com")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||||
|
|
||||||
|
assert has_element?(view, "#subscribers")
|
||||||
|
assert render(view) =~ "listed@example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "filters by status", %{conn: conn} do
|
||||||
|
confirmed_subscriber_fixture(email: "confirmed@example.com")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||||
|
|
||||||
|
# Filter to confirmed only
|
||||||
|
view |> element("button", "Confirmed") |> render_click()
|
||||||
|
|
||||||
|
assert render(view) =~ "confirmed@example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "searches subscribers", %{conn: conn} do
|
||||||
|
confirmed_subscriber_fixture(email: "findme@example.com")
|
||||||
|
confirmed_subscriber_fixture(email: "other@example.com")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||||
|
|
||||||
|
view |> element("form") |> render_change(%{search: "findme"})
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ "findme@example.com"
|
||||||
|
refute html =~ "other@example.com"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes subscriber", %{conn: conn} do
|
||||||
|
confirmed_subscriber_fixture(email: "deleteme@example.com")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=subscribers")
|
||||||
|
|
||||||
|
assert render(view) =~ "deleteme@example.com"
|
||||||
|
|
||||||
|
view |> element("button", "Delete") |> render_click()
|
||||||
|
|
||||||
|
refute render(view) =~ "deleteme@example.com"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "campaigns tab" do
|
||||||
|
test "lists campaigns", %{conn: conn} do
|
||||||
|
campaign_fixture(subject: "Test campaign")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||||
|
|
||||||
|
assert render(view) =~ "Test campaign"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "deletes draft campaign", %{conn: conn} do
|
||||||
|
campaign_fixture(subject: "Delete me")
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||||
|
|
||||||
|
assert render(view) =~ "Delete me"
|
||||||
|
|
||||||
|
view |> element("button", "Delete") |> render_click()
|
||||||
|
|
||||||
|
refute render(view) =~ "Delete me"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "links to new campaign form", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/admin/newsletter?tab=campaigns")
|
||||||
|
|
||||||
|
assert has_element?(view, "a[href='/admin/newsletter/campaigns/new']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /admin/newsletter/export" do
|
||||||
|
test "downloads CSV of subscribers", %{conn: conn} do
|
||||||
|
confirmed_subscriber_fixture(email: "export@example.com")
|
||||||
|
|
||||||
|
conn = get(conn, ~p"/admin/newsletter/export")
|
||||||
|
|
||||||
|
assert response_content_type(conn, :csv) =~ "text/csv"
|
||||||
|
assert get_resp_header(conn, "content-disposition") |> hd() =~ "attachment"
|
||||||
|
assert conn.resp_body =~ "export@example.com"
|
||||||
|
assert conn.resp_body =~ "email,status"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
40
test/support/fixtures/newsletter_fixtures.ex
Normal file
40
test/support/fixtures/newsletter_fixtures.ex
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
defmodule Berrypod.NewsletterFixtures do
|
||||||
|
@moduledoc """
|
||||||
|
Test helpers for creating newsletter subscribers and campaigns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias Berrypod.Newsletter
|
||||||
|
|
||||||
|
def unique_email, do: "user#{System.unique_integer([:positive])}@example.com"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a confirmed subscriber directly via Repo, bypassing the
|
||||||
|
confirmation email flow.
|
||||||
|
"""
|
||||||
|
def confirmed_subscriber_fixture(attrs \\ %{}) do
|
||||||
|
now = DateTime.utc_now() |> DateTime.truncate(:second)
|
||||||
|
|
||||||
|
attrs =
|
||||||
|
Enum.into(attrs, %{
|
||||||
|
email: unique_email(),
|
||||||
|
status: "confirmed",
|
||||||
|
confirmed_at: now,
|
||||||
|
consent_text: "Test signup"
|
||||||
|
})
|
||||||
|
|
||||||
|
%Newsletter.Subscriber{}
|
||||||
|
|> Newsletter.Subscriber.changeset(attrs)
|
||||||
|
|> Berrypod.Repo.insert!()
|
||||||
|
end
|
||||||
|
|
||||||
|
def campaign_fixture(attrs \\ %{}) do
|
||||||
|
attrs =
|
||||||
|
Enum.into(attrs, %{
|
||||||
|
subject: "Test campaign #{System.unique_integer([:positive])}",
|
||||||
|
body: "Hello! Unsubscribe: {{unsubscribe_url}}"
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, campaign} = Newsletter.create_campaign(attrs)
|
||||||
|
campaign
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user