Replace put_flash with inline feedback for form saves: - Media library: metadata save shows "Saved" checkmark - Product show: storefront controls save shows "Saved" checkmark - Newsletter campaign form: draft save shows "Saved" checkmark Page-level outcomes (uploads, deletes, async operations) remain as flash/banner messages — these are the correct pattern for non-form actions. Completes Task 4 of notification overhaul. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
274 lines
8.4 KiB
Elixir
274 lines
8.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))
|
|
|> assign(:save_status, :idle)}
|
|
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), save_status: :idle)}
|
|
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)
|
|
|> assign(:save_status, :saved)}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, assign(socket, :save_status, :error)}
|
|
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("send_test", _params, socket) do
|
|
params = current_form_params(socket)
|
|
to_email = socket.assigns.current_scope.user.email
|
|
|
|
if params["subject"] == "" || params["body"] == "" do
|
|
{:noreply, put_flash(socket, :error, "Fill in subject and body first")}
|
|
else
|
|
# Build a temporary campaign struct for the notifier
|
|
campaign = %Newsletter.Campaign{
|
|
subject: params["subject"],
|
|
body: params["body"]
|
|
}
|
|
|
|
case Newsletter.Notifier.deliver_test(campaign, to_email) do
|
|
{:ok, _} ->
|
|
{:noreply, put_flash(socket, :info, "Test email sent to #{to_email}")}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, "Failed to send test email")}
|
|
end
|
|
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="admin-content-medium admin-section">
|
|
<.form for={@form} phx-change="validate" phx-submit="save_draft">
|
|
<div class="admin-stack">
|
|
<.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="admin-help-text"
|
|
>
|
|
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="admin-warning-text"
|
|
>
|
|
<.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>
|
|
<summary class="admin-preview-summary">
|
|
Preview
|
|
</summary>
|
|
<pre class="admin-preview-body">{preview_body(@form[:body].value)}</pre>
|
|
</details>
|
|
<% end %>
|
|
|
|
<div
|
|
:if={!readonly?(@campaign)}
|
|
class="admin-campaign-actions"
|
|
>
|
|
<.button type="submit">
|
|
Save draft
|
|
</.button>
|
|
<.inline_feedback status={@save_status} />
|
|
|
|
<button
|
|
type="button"
|
|
phx-click="send_test"
|
|
class="admin-btn admin-btn-ghost"
|
|
>
|
|
<.icon name="hero-envelope" class="size-4" /> Send test
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
phx-click="send_now"
|
|
data-confirm={"Send this campaign to #{@subscriber_count} subscribers now?"}
|
|
class="admin-btn admin-btn-primary admin-btn-success"
|
|
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="admin-readonly-actions">
|
|
<.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
|