237 lines
7.4 KiB
Elixir
237 lines
7.4 KiB
Elixir
|
|
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
|