add admin email settings page with provider selection
All checks were successful
deploy / deploy (push) Successful in 56s

Card radio component for picking email providers (SMTP, SendGrid, Mailjet, etc.)
with instant client-side switching via JS hook. Adapter configs are pre-rendered
and toggled without a server round-trip. Secrets are preserved when re-saving
with blank password fields. Includes from address field, test email sending,
and disconnect flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-21 19:29:34 +00:00
parent a2e46664c6
commit 366a1e6a48
17 changed files with 1176 additions and 39 deletions

View File

@@ -519,6 +519,99 @@ defmodule BerrypodWeb.CoreComponents do
"""
end
@doc """
Renders a radio card group — a set of selectable cards backed by radio inputs.
Each option is a map with `:value` and `:name`, plus optional `:description`,
`:tags`, `:url`, `:badge`, and `:disabled` keys.
The `display` attr controls card content layout:
- `:tags` (default) — name + tag pills + short description
- `:description` — name + description text + link
## Examples
<.card_radio_group
name="email[adapter]"
value={@selected}
legend="Email provider"
options={[
%{value: "postmark", name: "Postmark", description: "Fast email.", tags: ["Transactional", "US"]},
%{value: "smtp", name: "SMTP", description: "Any SMTP server.", tags: ["Any type"]}
]}
/>
"""
attr :name, :string, required: true
attr :value, :string, default: nil
attr :legend, :string, required: true
attr :options, :list, required: true
attr :disabled, :boolean, default: false
attr :display, :atom, default: :tags, values: [:description, :tags]
def card_radio_group(assigns) do
~H"""
<fieldset class="card-radio-fieldset" disabled={@disabled}>
<legend class="admin-label">{@legend}</legend>
<div class="card-radio-grid">
<label
:for={option <- @options}
class={[
"card-radio-card",
@value == option.value && "card-radio-card-selected",
option[:disabled] && "card-radio-card-disabled"
]}
>
<input
type="radio"
id={"#{@name}-#{option.value}"}
name={@name}
value={option.value}
checked={@value == option.value}
disabled={option[:disabled] || @disabled}
class="card-radio-input"
/>
<span class="card-radio-name">{option.name}</span>
<.card_radio_content option={option} display={@display} />
</label>
</div>
</fieldset>
"""
end
attr :option, :map, required: true
attr :display, :atom, required: true
defp card_radio_content(%{display: :tags} = assigns) do
~H"""
<span :if={@option[:tags]} class="card-radio-tags">
<span :for={tag <- @option.tags} class="card-radio-tag">{tag}</span>
</span>
<span :if={@option[:description]} class="card-radio-description">
{@option.description}
</span>
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
"""
end
defp card_radio_content(assigns) do
~H"""
<span :if={@option[:description]} class="card-radio-description">
{@option.description}
</span>
<span :if={@option[:badge]} class="card-radio-badge">{@option.badge}</span>
<a
:if={@option[:url]}
href={@option.url}
target="_blank"
rel="noopener"
class="card-radio-link"
onclick="event.stopPropagation();"
>
{@option.name} &nearr;
</a>
"""
end
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.exec("showModal()", to: "##{id}")

View File

@@ -50,6 +50,10 @@ defmodule BerrypodWeb.Layouts do
if current_path == "/admin", do: "active", else: nil
end
def admin_nav_active?(current_path, "/admin/settings") do
if current_path == "/admin/settings", do: "active", else: nil
end
def admin_nav_active?(current_path, link_path) do
if String.starts_with?(current_path, link_path), do: "active", else: nil
end

View File

@@ -23,6 +23,9 @@
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
<p>
Email delivery isn't set up yet — customers won't receive order confirmations or shipping updates.
<.link navigate={~p"/admin/settings/email"} class="underline font-medium">
Configure email
</.link>
</p>
</div>
@@ -99,6 +102,14 @@
<.icon name="hero-cog-6-tooth" class="size-5" /> Settings
</.link>
</li>
<li>
<.link
navigate={~p"/admin/settings/email"}
class={admin_nav_active?(@current_path, "/admin/settings/email")}
>
<.icon name="hero-envelope" class="size-5" /> Email
</.link>
</li>
</ul>
</nav>