From db130a71554ba2e6c616a5785e4a5b4c698418b3 Mon Sep 17 00:00:00 2001 From: jamey Date: Wed, 4 Mar 2026 23:10:37 +0000 Subject: [PATCH] rework email settings for true progressive enhancement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render all adapter field sections in the form with CSS :has(:checked) controlling visibility. Selecting a provider instantly shows its config fields — no JS, no page reload, no server round-trip needed. - Render all 6 adapter configs with data-adapter attribute - CSS :has(:checked) show/hide rules per adapter in admin stylesheet - Namespace field names per adapter (email[brevo][api_key] etc) - Drop 4 transactional-only providers (Resend, Postmark, Mailgun, MailPace) - Remove noscript "Switch provider" button and controller redirect workaround - Remove configured_adapter hidden input tracking - Hide JS-only test email button for no-JS users via noscript style - LiveView progressively enhances with async save and test email Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 4 +- assets/css/admin/components.css | 16 +- docs/plans/onboarding-ux.md | 37 ++- lib/berrypod/key_validation.ex | 67 ----- lib/berrypod/mailer/adapters.ex | 58 ---- .../controllers/email_settings_controller.ex | 31 +-- lib/berrypod_web/live/admin/email_settings.ex | 256 ++++++++---------- test/berrypod/key_validation_test.exs | 74 +---- test/berrypod/mailer/adapters_test.exs | 9 +- test/berrypod/mailer_test.exs | 14 +- .../email_settings_controller_test.exs | 18 +- .../live/admin/email_settings_test.exs | 85 ++---- 12 files changed, 213 insertions(+), 456 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 27c077f..796daca 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -24,7 +24,7 @@ Tier 1 MVP complete. Tier 2 production readiness complete (except Litestream and - Abandoned cart recovery (GDPR-compliant, single email) - Favicon generation from source image (PNG variants, SVG dark mode, webmanifest) - Complete SEO (OG/Twitter cards, JSON-LD, sitemap, canonical URLs, meta descriptions) -- Email settings admin with 10 adapter options and test email +- Email settings admin with 6 adapter options, no-JS support, and test email - No-JS support across all key flows - Fully Tailwind-free CSS (12 KB gzipped shop+theme, 95 KB gzipped admin total) - CI pipeline (compile warnings, format, credo, dialyzer, tests) @@ -60,7 +60,7 @@ Based on usability testing (March 2026). Reworks the entire setup flow into a si | A | Simplify initial setup to account creation only (email, password, shop name) | 1.5h | planned | | B | Guided setup flow with progress bar (multi-step, skippable, explains "why") | 4h | planned | | C | Forgiving API key validation (strip whitespace, format checks, helpful errors) | 1.5h | done | -| D | Email provider setup UX rework (recommended pick, grouping, guided flow, test email) | 2h | planned | +| D | Email provider setup UX rework (6 adapters, no-JS, `:has(:checked)` CSS, test email) | 2h | done | | E | Contextual prompts for skipped steps (products, checkout, order detail) | 2h | done | | F | Dashboard checklist and messaging rework | 2h | planned | | G | Coming soon page fixes (logo layout, admin login link) | 30m | done | diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index e5d554d..8e85097 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -1492,7 +1492,7 @@ outline-offset: 2px; } - &.card-radio-card-selected { + &:has(:checked) { border-color: var(--t-text-primary, #171717); background: var(--t-surface-sunken, #e5e5e5); } @@ -4379,6 +4379,20 @@ gap: 1.5rem; } +/* Show only the adapter config matching the checked radio */ +.admin-adapter-config[data-adapter] { display: none; } + +.admin-content-medium:has(#email-adapter-brevo:checked) [data-adapter="brevo"], +.admin-content-medium:has(#email-adapter-sendgrid:checked) [data-adapter="sendgrid"], +.admin-content-medium:has(#email-adapter-mailjet:checked) [data-adapter="mailjet"], +.admin-content-medium:has(#email-adapter-mailersend:checked) [data-adapter="mailersend"], +.admin-content-medium:has(#email-adapter-smtp:checked) [data-adapter="smtp"], +.admin-content-medium:has(#email-adapter-postal:checked) [data-adapter="postal"] { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + /* ── Campaign form ── */ .admin-campaign-actions { diff --git a/docs/plans/onboarding-ux.md b/docs/plans/onboarding-ux.md index 067e58e..2e4e100 100644 --- a/docs/plans/onboarding-ux.md +++ b/docs/plans/onboarding-ux.md @@ -1,6 +1,6 @@ # Onboarding UX v2 -Status: Planned +Status: In progress Supersedes the original onboarding-ux plan. Based on usability testing session (March 2026) covering the setup wizard, launch checklist, email provider setup, and general onboarding flow. @@ -77,20 +77,31 @@ API key entry should be as forgiving as possible: **Files:** `lib/berrypod_web/live/setup/onboarding.ex`, `lib/berrypod_web/live/admin/providers.ex`, validation logic in contexts -### D. Email provider setup UX +### D. Email provider setup UX — done -Rework the email provider selection and configuration: +Reworked the email provider selection and configuration. Changes across two sessions: -- **Recommended pick** highlighted at top — whichever has the best free tier, easiest setup, and supports newsletters. One clear "if you're not sure, pick this" option. -- **Two groups:** "Also sends newsletters" vs "Transactional only" with one-line explanation of the difference -- **One-liner per provider:** free tier info, setup difficulty ("paste one API key" vs "requires domain verification") -- **Self-hosted/SMTP** in a collapsible "Advanced" section at bottom -- **Guided flow after selection:** link to create account (new tab), then "Now enter these settings:" with config fields -- **Send test email** button after configuration -- **Remove** the masked key display ("Current: 84159e26•••4f3") -- **Default from address** to the admin email automatically — don't surface during setup, it's a general settings thing for later +**Session 1 (commit dd659e4):** +- Recommended pick (Brevo) highlighted with badge +- Two groups: "Also sends newsletters" vs "Transactional only" +- One-liner per provider with free tier info +- Self-hosted/SMTP in collapsible "Advanced" section +- Guided flow after selection with config fields +- Send test email button with async delivery +- Removed masked key display +- Default from address auto-set from admin email +- No-JS support via controller POST fallback +- 17 audit fixes (a11y, error handling, UX) -**Files:** `lib/berrypod_web/live/admin/email_settings.ex`, shared components for guided flow +**Session 2 (uncommitted):** +- Dropped 4 transactional-only providers (Resend, Postmark, Mailgun, MailPace) — all are transactional-only and not useful for a shop that needs newsletters. 6 adapters remain: Brevo, SendGrid, Mailjet, MailerSend + SMTP, Postal +- Removed "Popular providers" heading and description (unnecessary with fewer providers) +- Fixed no-JS provider switching: noscript submit button always visible with dynamic text +- Fixed no-JS radio highlight: replaced server-applied `card-radio-card-selected` class with pure CSS `:has(:checked)` selector +- Cleaned up key validation (removed 4 provider clauses, removed 2 known prefixes) +- Updated all related tests (adapters, key validation, email settings LiveView, controller, mailer) + +**Files:** `lib/berrypod_web/live/admin/email_settings.ex`, `lib/berrypod/mailer/adapters.ex`, `lib/berrypod/key_validation.ex`, `assets/css/admin/components.css`, `lib/berrypod_web/controllers/email_settings_controller.ex` ### E. Contextual prompts for skipped steps @@ -148,7 +159,7 @@ Increase input field border contrast to meet WCAG AA (3:1 minimum for UI compone | A | Simplify initial setup to account creation only | 1.5h | planned | | B | Guided setup flow with progress bar | 4h | planned | | C | Forgiving API key validation | 1.5h | planned | -| D | Email provider setup UX rework | 2h | planned | +| D | Email provider setup UX rework | 2h | done | | E | Contextual prompts for skipped steps | 2h | done | | F | Dashboard checklist and messaging rework | 2h | planned | | G | Coming soon page fixes (logo + admin link) | 30m | planned | diff --git a/lib/berrypod/key_validation.ex b/lib/berrypod/key_validation.ex index cd99b0a..ad3b727 100644 --- a/lib/berrypod/key_validation.ex +++ b/lib/berrypod/key_validation.ex @@ -84,8 +84,6 @@ defmodule Berrypod.KeyValidation do @known_prefixes [ {"SG.", "SendGrid"}, {"xkeysib-", "Brevo"}, - {"re_", "Resend"}, - {"key-", "Mailgun"}, {"mlsn.", "MailerSend"} ] @@ -98,56 +96,6 @@ defmodule Berrypod.KeyValidation do end end - # Postmark: UUID format - defp validate_email_format(key, "postmark", "api_key") do - uuid_pattern = ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i - - cond do - Regex.match?(uuid_pattern, key) -> - {:ok, key} - - wrong = wrong_provider_hint(key, "postmark") -> - {:error, wrong} - - true -> - {:error, - "Postmark server tokens are UUIDs (like abc12345-abcd-1234-abcd-123456789abc). Make sure you copy the server token, not the account ID"} - end - end - - # Resend: re_ prefix - defp validate_email_format(key, "resend", "api_key") do - cond do - String.starts_with?(key, "re_") -> - {:ok, key} - - wrong = wrong_provider_hint(key, "resend") -> - {:error, wrong} - - true -> - {:error, - "Resend API keys start with re_ — find yours in your Resend dashboard under API Keys"} - end - end - - # Mailgun: key- prefix (classic) or long RBAC keys - defp validate_email_format(key, "mailgun", "api_key") do - cond do - String.starts_with?(key, "key-") -> - {:ok, key} - - String.length(key) >= 20 -> - {:ok, key} - - wrong = wrong_provider_hint(key, "mailgun") -> - {:error, wrong} - - true -> - {:error, - "Mailgun API keys usually start with key- — find yours in Settings → API Security"} - end - end - # Brevo: xkeysib- prefix defp validate_email_format(key, "brevo", "api_key") do cond do @@ -210,21 +158,6 @@ defmodule Berrypod.KeyValidation do end end - # MailPace: server token, no known prefix - defp validate_email_format(key, "mailpace", "api_key") do - cond do - String.length(key) >= 10 -> - {:ok, key} - - wrong = wrong_provider_hint(key, "mailpace") -> - {:error, wrong} - - true -> - {:error, - "This looks too short — find your server API token under your domain settings in MailPace"} - end - end - # Non-api_key fields (domain, relay, base_url, etc.), basic checks defp validate_email_format(key, _adapter_key, _field_key) do if String.length(key) < 3 do diff --git a/lib/berrypod/mailer/adapters.ex b/lib/berrypod/mailer/adapters.ex index 0d2b483..b739cc1 100644 --- a/lib/berrypod/mailer/adapters.ex +++ b/lib/berrypod/mailer/adapters.ex @@ -69,64 +69,6 @@ defmodule Berrypod.Mailer.Adapters do %Field{key: "api_key", label: "API key", type: :secret, required: true} ] }, - # ── Transactional only ── - %Adapter{ - key: "resend", - name: "Resend", - module: Swoosh.Adapters.Resend, - description: "Developer-friendly API, simple setup.", - tags: ["Transactional", "US"], - category: :transactional, - free_tier: "3,000 emails/month free", - setup_hint: "Paste one API key", - url: "https://resend.com", - fields: [ - %Field{key: "api_key", label: "API key", type: :secret, required: true} - ] - }, - %Adapter{ - key: "postmark", - name: "Postmark", - module: Swoosh.Adapters.Postmark, - description: "Excellent deliverability tracking.", - tags: ["Transactional", "US"], - category: :transactional, - free_tier: "100 emails/month free", - setup_hint: "Paste one API key", - url: "https://postmarkapp.com", - fields: [ - %Field{key: "api_key", label: "API key", type: :secret, required: true} - ] - }, - %Adapter{ - key: "mailgun", - name: "Mailgun", - module: Swoosh.Adapters.Mailgun, - description: "EU region option available.", - tags: ["Transactional", "EU option", "Sweden"], - category: :transactional, - free_tier: "100 emails/day trial", - setup_hint: "API key + domain name", - url: "https://www.mailgun.com", - fields: [ - %Field{key: "api_key", label: "API key", type: :secret, required: true}, - %Field{key: "domain", label: "Domain", type: :string, required: true} - ] - }, - %Adapter{ - key: "mailpace", - name: "MailPace", - module: Swoosh.Adapters.MailPace, - description: "Privacy-focused, simple API.", - tags: ["Transactional", "UK"], - category: :transactional, - free_tier: "3,000 emails/month free", - setup_hint: "Paste one API key", - url: "https://mailpace.com", - fields: [ - %Field{key: "api_key", label: "API key", type: :secret, required: true} - ] - }, # ── Advanced ── %Adapter{ key: "smtp", diff --git a/lib/berrypod_web/controllers/email_settings_controller.ex b/lib/berrypod_web/controllers/email_settings_controller.ex index 5594de1..bb7e3d2 100644 --- a/lib/berrypod_web/controllers/email_settings_controller.ex +++ b/lib/berrypod_web/controllers/email_settings_controller.ex @@ -10,27 +10,22 @@ defmodule BerrypodWeb.EmailSettingsController do alias Berrypod.Mailer def update(conn, %{"email" => params}) do - selected = params["adapter"] - configured = params["configured_adapter"] + adapter_key = params["adapter"] + # Fields are namespaced: email[brevo][api_key] → params["brevo"]["api_key"] + adapter_params = params[adapter_key] || %{} - if selected != configured do - # User changed adapter radio but config fields are for the old adapter. - # Redirect to show the new adapter's config fields. - redirect(conn, to: ~p"/admin/settings/email?adapter=#{selected}") - else - case Mailer.save_config(selected, params, conn.assigns.current_scope.user.email) do - {:ok, _adapter_info} -> - conn - |> put_flash(:info, "Settings saved — send a test email to check it works") - |> redirect(to: ~p"/admin/settings/email") + case Mailer.save_config(adapter_key, adapter_params, conn.assigns.current_scope.user.email) do + {:ok, _adapter_info} -> + conn + |> put_flash(:info, "Settings saved — send a test email to check it works") + |> redirect(to: ~p"/admin/settings/email") - {:error, field_errors} when is_map(field_errors) -> - message = field_errors |> Map.values() |> Enum.join(". ") + {:error, field_errors} when is_map(field_errors) -> + message = field_errors |> Map.values() |> Enum.join(". ") - conn - |> put_flash(:error, message) - |> redirect(to: ~p"/admin/settings/email?adapter=#{selected}") - end + conn + |> put_flash(:error, message) + |> redirect(to: ~p"/admin/settings/email") end end diff --git a/lib/berrypod_web/live/admin/email_settings.ex b/lib/berrypod_web/live/admin/email_settings.ex index 1375be5..236cbe0 100644 --- a/lib/berrypod_web/live/admin/email_settings.ex +++ b/lib/berrypod_web/live/admin/email_settings.ex @@ -8,21 +8,23 @@ defmodule BerrypodWeb.Admin.EmailSettings do @impl true def mount(_params, _session, socket) do env_locked = Mailer.env_var_configured?() - {current_adapter, current_values} = Mailer.current_config() + {current_adapter, _current_values} = Mailer.current_config() saved_adapter = Settings.get_setting("email_adapter") adapter_key = current_adapter || saved_adapter grouped = Adapters.grouped() + all_adapters = Adapters.all() + all_values = load_all_adapter_values() {:ok, socket |> assign(:page_title, "Email settings") |> assign(:env_locked, env_locked) |> assign(:adapter_key, adapter_key) - |> assign(:current_values, current_values) + |> assign(:all_values, all_values) |> assign(:all_email_adapters, grouped[:all_email] || []) - |> assign(:transactional_adapters, grouped[:transactional] || []) |> assign(:advanced_adapters, grouped[:advanced] || []) + |> assign(:all_adapters, all_adapters) |> assign(:email_configured, Mailer.email_configured?()) |> assign(:selected_adapter, adapter_key && Adapters.get(adapter_key)) |> assign(:sending_test, false) @@ -36,37 +38,14 @@ defmodule BerrypodWeb.Admin.EmailSettings do @impl true def handle_params(params, _uri, socket) do - # Support ?adapter=X for no-JS adapter switching - adapter_key = params["adapter"] || socket.assigns.adapter_key - - socket = - if adapter_key && adapter_key != socket.assigns.adapter_key do - values = load_adapter_values(adapter_key) - - socket - |> assign(:adapter_key, adapter_key) - |> assign(:selected_adapter, Adapters.get(adapter_key)) - |> assign(:current_values, values) - |> assign(:field_errors, %{}) - |> assign(:test_result, nil) - |> assign(:test_error, nil) - else - socket - end - {:noreply, assign(socket, :from_checklist, params["from"] == "checklist")} end - defp load_adapter_values(nil), do: %{} - - defp load_adapter_values(adapter_key) do - case Adapters.get(adapter_key) do - nil -> - %{} - - adapter_info -> - for field <- adapter_info.fields, into: %{} do - settings_key = Adapters.settings_key(adapter_key, field.key) + defp load_all_adapter_values do + for adapter <- Adapters.all(), into: %{} do + values = + for field <- adapter.fields, into: %{} do + settings_key = Adapters.settings_key(adapter.key, field.key) value = case field.type do @@ -76,6 +55,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do {field.key, value} end + + {adapter.key, values} end end @@ -84,13 +65,10 @@ defmodule BerrypodWeb.Admin.EmailSettings do if key == socket.assigns.adapter_key do {:noreply, socket} else - values = load_adapter_values(key) - {:noreply, socket |> assign(:adapter_key, key) |> assign(:selected_adapter, Adapters.get(key)) - |> assign(:current_values, values) |> assign(:field_errors, %{}) |> assign(:test_result, nil) |> assign(:test_error, nil)} @@ -102,16 +80,20 @@ defmodule BerrypodWeb.Admin.EmailSettings do {:noreply, put_flash(socket, :error, "Email config is controlled by environment variables")} else adapter_key = params["adapter"] + # Fields are namespaced: email[brevo][api_key] → params["brevo"]["api_key"] + adapter_params = params[adapter_key] || %{} - case Mailer.save_config(adapter_key, params, socket.assigns.current_scope.user.email) do + case Mailer.save_config( + adapter_key, + adapter_params, + socket.assigns.current_scope.user.email + ) do {:ok, _adapter_info} -> - {current_adapter, current_values} = Mailer.current_config() - {:noreply, socket - |> assign(:adapter_key, current_adapter) - |> assign(:selected_adapter, Adapters.get(current_adapter)) - |> assign(:current_values, current_values) + |> assign(:adapter_key, adapter_key) + |> assign(:selected_adapter, Adapters.get(adapter_key)) + |> assign(:all_values, load_all_adapter_values()) |> assign(:email_configured, Mailer.email_configured?()) |> assign(:field_errors, %{}) |> assign(:test_result, nil) @@ -204,13 +186,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do phx-change="form_change" phx-submit="save" > - <%!-- Hidden field tracks which adapter's config fields are rendered --%> - - <%!-- Step 1: Choose a provider --%>
@@ -221,16 +196,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do
Email provider -

Popular providers

-

- Newsletters and transactional emails. All have free tiers. - - Not sure which? Pick the recommended one. - -

<.provider_card :for={adapter <- @all_email_adapters} @@ -240,20 +205,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do />
-

Transactional only

-

- Order confirmations and shipping updates. - You'll need a separate service for newsletters later. -

-
- <.provider_card - :for={adapter <- @transactional_adapters} - adapter={adapter} - selected={@adapter_key} - disabled={@env_locked} - /> -
-
Already have your own email server? @@ -270,60 +221,24 @@ defmodule BerrypodWeb.Admin.EmailSettings do
- <%!-- Steps 2 & 3 appear for the selected adapter only --%> -
- <%!-- Step 2: Create an account (providers with sign-up URLs) --%> -
-
- 2 -

Create a free account

-
-

- <.external_link href={@selected_adapter.url} class="admin-link"> - Sign up at {@selected_adapter.name} ↗ - - if you don't already have an account. It's free. -

-
- - <%!-- Step 3 (or 2 for advanced): Paste your key --%> -
-
- - {if @selected_adapter.url, do: "3", else: "2"} - -

{adapter_fields_title(@selected_adapter)}

-
-

{adapter_fields_instruction(@selected_adapter)}

- <%= for field <- @selected_adapter.fields do %> - <.adapter_field_static - field_def={field} - value={@current_values[field.key]} - disabled={@env_locked} - error={@field_errors[field.key]} - /> - <% end %> - <%= unless @env_locked do %> -
- <.button phx-disable-with="Saving..."> - Save settings - -
- <% end %> -
-
- - <%!-- No-JS: if adapter changed but config not yet shown, submit to reload --%> - + <%!-- Steps 2 & 3 for each adapter (CSS :has(:checked) shows the active one) --%> + <.adapter_config + :for={adapter <- @all_adapters} + adapter={adapter} + values={@all_values[adapter.key] || %{}} + field_errors={if(@adapter_key == adapter.key, do: @field_errors, else: %{})} + env_locked={@env_locked} + /> <%!-- Step 4: Send a test email (only after config saved) --%> -
+
{@current_scope.user.email} to check everything works.

- <.button - type="button" - phx-click="send_test" - disabled={@sending_test} - phx-disable-with="Sending..." - > - <.icon name="hero-paper-airplane" class="size-4" /> Send test email - - <%!-- No-JS fallback for test email --%> + <%!-- JS: async send via LiveView --%> + + <.button + type="button" + phx-click="send_test" + disabled={@sending_test} + phx-disable-with="Sending..." + > + <.icon name="hero-paper-airplane" class="size-4" /> Send test email + + + <%!-- No-JS: form POST fallback (hides the JS button above) --%>
@@ -422,6 +345,58 @@ defmodule BerrypodWeb.Admin.EmailSettings do # ── Local components ── + attr :adapter, :map, required: true + attr :values, :map, required: true + attr :field_errors, :map, required: true + attr :env_locked, :boolean, required: true + + defp adapter_config(assigns) do + ~H""" +
+ <%!-- Create an account (providers with sign-up URLs) --%> +
+
+ 2 +

Create a free account

+
+

+ <.external_link href={@adapter.url} class="admin-link"> + Sign up at {@adapter.name} ↗ + + if you don't already have an account. It's free. +

+
+ + <%!-- Paste your key / server details --%> +
+
+ + {if @adapter.url, do: "3", else: "2"} + +

{adapter_fields_title(@adapter)}

+
+

{adapter_fields_instruction(@adapter)}

+ <%= for field <- @adapter.fields do %> + <.adapter_field_input + adapter_key={@adapter.key} + field_def={field} + value={@values[field.key]} + disabled={@env_locked} + error={@field_errors[field.key]} + /> + <% end %> + <%= unless @env_locked do %> +
+ <.button phx-disable-with="Saving..."> + Save settings + +
+ <% end %> +
+
+ """ + end + defp adapter_fields_title(%{key: "smtp"}), do: "Enter your server details" defp adapter_fields_title(%{key: "postal"}), do: "Enter your server details" defp adapter_fields_title(%{key: "mailjet"}), do: "Paste your API keys" @@ -448,10 +423,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do defp provider_card(assigns) do ~H""" -