Packages
phoenix_kit
1.7.79
1.7.208
1.7.207
1.7.206
1.7.205
1.7.204
1.7.203
1.7.202
1.7.201
1.7.200
1.7.199
1.7.198
1.7.197
1.7.196
1.7.194
1.7.193
1.7.192
1.7.191
1.7.190
1.7.189
1.7.187
1.7.186
1.7.185
1.7.184
1.7.183
1.7.182
1.7.181
1.7.180
1.7.179
1.7.178
1.7.177
1.7.176
1.7.175
1.7.174
1.7.173
1.7.172
1.7.171
1.7.170
1.7.169
1.7.168
1.7.167
1.7.166
1.7.165
1.7.164
1.7.162
1.7.161
1.7.160
1.7.159
1.7.157
1.7.156
1.7.155
1.7.154
1.7.153
1.7.152
1.7.151
1.7.150
1.7.149
1.7.146
1.7.145
1.7.144
1.7.143
1.7.138
1.7.133
1.7.132
1.7.131
1.7.130
1.7.128
1.7.126
1.7.125
1.7.121
1.7.120
1.7.119
1.7.118
1.7.117
1.7.116
1.7.115
1.7.114
1.7.113
1.7.112
1.7.111
1.7.110
1.7.109
1.7.108
1.7.107
1.7.106
1.7.105
1.7.104
1.7.103
1.7.102
1.7.101
1.7.100
1.7.99
1.7.98
1.7.97
1.7.96
1.7.95
1.7.94
1.7.93
1.7.92
1.7.91
1.7.90
1.7.89
1.7.88
1.7.87
1.7.86
1.7.85
1.7.84
1.7.83
1.7.82
1.7.81
1.7.80
1.7.79
1.7.78
1.7.77
1.7.76
1.7.75
1.7.74
1.7.71
1.7.70
1.7.69
1.7.66
1.7.65
1.7.64
1.7.63
1.7.62
1.7.61
1.7.59
1.7.58
1.7.57
1.7.56
1.7.55
1.7.54
1.7.53
1.7.52
1.7.51
1.7.49
1.7.44
1.7.43
1.7.42
1.7.41
1.7.39
1.7.38
1.7.37
1.7.36
1.7.34
1.7.33
1.7.31
1.7.30
1.7.29
1.7.28
1.7.27
1.7.26
1.7.25
1.7.24
1.7.23
1.7.22
1.7.21
1.7.20
1.7.19
1.7.18
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.20
1.6.19
1.6.18
1.6.17
1.6.16
1.6.15
1.6.14
1.6.13
1.6.12
1.6.11
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.5.2
1.5.1
1.5.0
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.2
1.3.1
1.3.0
1.2.10
1.2.9
1.2.8
1.2.7
1.2.5
1.2.4
1.2.2
1.2.1
1.2.0
1.1.0
1.0.0
A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more
Current section
Files
Jump to
Current section
Files
lib/modules/emails/web/template_editor.html.heex
<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title={@page_title}
current_path={@url_path}
project_title={@project_title}
>
<div class="container flex-col mx-auto px-4 py-6">
<.admin_page_header back={Routes.path("/admin/emails/templates")}>
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold text-base-content">{@page_title}</h1>
<%= if @mode == :edit and @template do %>
<% source_module = PhoenixKit.Modules.Emails.Template.get_source_module(@template) %>
<p class="text-sm text-base-content/60 mt-0.5">
Version {@template.version} • {String.capitalize(@template.category)} • {String.capitalize(
source_module
)} • {String.capitalize(@template.status)}
</p>
<% else %>
<p class="text-sm text-base-content/60 mt-0.5">
{gettext("Create a new email template")}
</p>
<% end %>
</.admin_page_header>
<%!-- Main Editor Grid --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<%!-- Editor Panel --%>
<div class="space-y-6">
<.form
for={@changeset}
as={:email_template}
phx-change="validate"
phx-submit="save"
class="space-y-6"
>
<%!-- Basic Information --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">{gettext("Basic Information")}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Template Name")}</span>
</label>
<input
type="text"
name="email_template[name]"
value={Ecto.Changeset.get_field(@changeset, :name)}
placeholder="welcome_email"
class="input input-bordered"
disabled={(@mode == :edit and @template) && @template.is_system}
/>
<% err = @changeset.action && Keyword.get(@changeset.errors, :name) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Display Name")}</span>
</label>
<%!-- Hidden inputs preserve all locale values --%>
<%= for locale <- @available_locales, locale != @current_editor_locale do %>
<input
type="hidden"
name={"email_template[display_name][#{locale}]"}
value={
PhoenixKit.Modules.Emails.Template.get_translation(
Ecto.Changeset.get_field(@changeset, :display_name) || %{},
locale
)
}
/>
<% end %>
<input
type="text"
name={"email_template[display_name][#{@current_editor_locale}]"}
value={
PhoenixKit.Modules.Emails.Template.get_translation(
Ecto.Changeset.get_field(@changeset, :display_name) || %{},
@current_editor_locale
)
}
placeholder="Welcome Email"
class="input input-bordered"
/>
<% err = @changeset.action && Keyword.get(@changeset.errors, :display_name) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Slug")}</span>
</label>
<input
type="text"
name="email_template[slug]"
value={Ecto.Changeset.get_field(@changeset, :slug)}
placeholder="welcome-email"
class="input input-bordered"
disabled={(@mode == :edit and @template) && @template.is_system}
/>
<% err = @changeset.action && Keyword.get(@changeset.errors, :slug) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
<label class="label">
<span class="label-text-alt">
{gettext("URL-friendly identifier (auto-generated from name if left empty)")}
</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Category")}</span>
</label>
<select
name="email_template[category]"
class="select select-bordered"
disabled={(@mode == :edit and @template) && @template.is_system}
>
<option
value="transactional"
selected={
Ecto.Changeset.get_field(@changeset, :category) == "transactional"
}
>
{gettext("Transactional")}
</option>
<option
value="marketing"
selected={Ecto.Changeset.get_field(@changeset, :category) == "marketing"}
>
{gettext("Marketing")}
</option>
<option
value="system"
selected={Ecto.Changeset.get_field(@changeset, :category) == "system"}
>
{gettext("System")}
</option>
<option
value="notification"
selected={Ecto.Changeset.get_field(@changeset, :category) == "notification"}
>
{gettext("Notification")}
</option>
<option
value="newsletters"
selected={Ecto.Changeset.get_field(@changeset, :category) == "newsletters"}
>
{gettext("Newsletters")}
</option>
</select>
<% err = @changeset.action && Keyword.get(@changeset.errors, :category) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Source Module")}</span>
</label>
<% current_metadata = Ecto.Changeset.get_field(@changeset, :metadata) || %{} %>
<% current_source_module = Map.get(current_metadata, "source_module", "custom") %>
<select
name="email_template[metadata][source_module]"
class="select select-bordered"
>
<option value="users" selected={current_source_module == "users"}>
{gettext("Users")}
</option>
<option value="billing" selected={current_source_module == "billing"}>
{gettext("Billing")}
</option>
<option value="publishing" selected={current_source_module == "publishing"}>
{gettext("Publishing")}
</option>
<option value="entities" selected={current_source_module == "entities"}>
{gettext("Entities")}
</option>
<option value="admin" selected={current_source_module == "admin"}>
{gettext("Admin")}
</option>
<option value="custom" selected={current_source_module == "custom"}>
{gettext("Custom")}
</option>
</select>
<label class="label">
<span class="label-text-alt">{gettext("Module that sends this email")}</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Status")}</span>
</label>
<select
name="email_template[status]"
class="select select-bordered"
>
<option
value="draft"
selected={Ecto.Changeset.get_field(@changeset, :status) == "draft"}
>
{gettext("Draft")}
</option>
<option
value="active"
selected={Ecto.Changeset.get_field(@changeset, :status) == "active"}
>
{gettext("Active")}
</option>
<option
value="archived"
selected={Ecto.Changeset.get_field(@changeset, :status) == "archived"}
>
{gettext("Archived")}
</option>
</select>
<% err = @changeset.action && Keyword.get(@changeset.errors, :status) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Description (Optional)")}</span>
</label>
<%!-- Hidden textareas preserve all locale values --%>
<%= for locale <- @available_locales, locale != @current_editor_locale do %>
<textarea name={"email_template[description][#{locale}]"} hidden><%= PhoenixKit.Modules.Emails.Template.get_translation(Ecto.Changeset.get_field(@changeset, :description) || %{}, locale) %></textarea>
<% end %>
<textarea
name={"email_template[description][#{@current_editor_locale}]"}
placeholder={gettext("Brief description of this template...")}
class="textarea textarea-bordered"
rows="2"
><%= PhoenixKit.Modules.Emails.Template.get_translation(Ecto.Changeset.get_field(@changeset, :description) || %{}, @current_editor_locale) %></textarea>
<% err = @changeset.action && Keyword.get(@changeset.errors, :description) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
</div>
</div>
</div>
<%!-- Email Content --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-lg">{gettext("Email Content")}</h2>
<%!-- Locale tab bar — only shown when multiple locales configured --%>
<%= if length(@available_locales) > 1 do %>
<div class="tabs tabs-border tabs-sm">
<%= for locale <- @available_locales do %>
<button
type="button"
class={"tab #{if locale == @current_editor_locale, do: "tab-active font-semibold", else: ""}"}
phx-click="switch_editor_locale"
phx-value-locale={locale}
>
{String.upcase(locale)}
</button>
<% end %>
</div>
<% end %>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Subject Line")}</span>
</label>
<%!-- Hidden inputs preserve all locale values --%>
<%= for locale <- @available_locales, locale != @current_editor_locale do %>
<input
type="hidden"
name={"email_template[subject][#{locale}]"}
value={
PhoenixKit.Modules.Emails.Template.get_translation(
Ecto.Changeset.get_field(@changeset, :subject) || %{},
locale
)
}
/>
<% end %>
<input
type="text"
name={"email_template[subject][#{@current_editor_locale}]"}
value={
PhoenixKit.Modules.Emails.Template.get_translation(
Ecto.Changeset.get_field(@changeset, :subject) || %{},
@current_editor_locale
)
}
placeholder={"Welcome to {{app_name}}! (#{String.upcase(@current_editor_locale)})"}
class="input input-bordered"
/>
<% err = @changeset.action && Keyword.get(@changeset.errors, :subject) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
<label class="label">
<span class="label-text-alt">
Use {"{{variable}}"} syntax for dynamic content
</span>
</label>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("HTML Body")}</span>
</label>
<%!-- Hidden textareas preserve all locale values --%>
<%= for locale <- @available_locales, locale != @current_editor_locale do %>
<textarea name={"email_template[html_body][#{locale}]"} hidden><%= PhoenixKit.Modules.Emails.Template.get_translation(Ecto.Changeset.get_field(@changeset, :html_body) || %{}, locale) %></textarea>
<% end %>
<textarea
name={"email_template[html_body][#{@current_editor_locale}]"}
class="textarea textarea-bordered font-mono"
rows="12"
placeholder={"<h1>Welcome {{user_name}}!</h1> (#{String.upcase(@current_editor_locale)})"}
><%= PhoenixKit.Modules.Emails.Template.get_translation(Ecto.Changeset.get_field(@changeset, :html_body) || %{}, @current_editor_locale) %></textarea>
<% err = @changeset.action && Keyword.get(@changeset.errors, :html_body) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Text Body")}</span>
</label>
<%!-- Hidden textareas preserve all locale values --%>
<%= for locale <- @available_locales, locale != @current_editor_locale do %>
<textarea name={"email_template[text_body][#{locale}]"} hidden><%= PhoenixKit.Modules.Emails.Template.get_translation(Ecto.Changeset.get_field(@changeset, :text_body) || %{}, locale) %></textarea>
<% end %>
<textarea
name={"email_template[text_body][#{@current_editor_locale}]"}
class="textarea textarea-bordered font-mono"
rows="8"
placeholder={"Welcome {{user_name}}! (#{String.upcase(@current_editor_locale)})"}
><%= PhoenixKit.Modules.Emails.Template.get_translation(Ecto.Changeset.get_field(@changeset, :text_body) || %{}, @current_editor_locale) %></textarea>
<% err = @changeset.action && Keyword.get(@changeset.errors, :text_body) %>
<%= if err do %>
<div class="text-sm text-error mt-1">{elem(err, 0)}</div>
<% end %>
</div>
</div>
</div>
<%!-- Template Variables --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">{gettext("Template Variables")}</h2>
<%= if length(@extracted_variables) > 0 do %>
<div class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="w-5 h-5" />
<div>
<div class="font-semibold">
{gettext("%{count} variable(s) detected and auto-added",
count: length(@extracted_variables)
)}
</div>
<div class="text-sm mt-1">
{gettext(
"Variables are automatically managed. Edit descriptions below or remove if not needed."
)}
</div>
</div>
</div>
<div class="space-y-2">
<%= for {name, description} <- Ecto.Changeset.get_field(@changeset, :variables) || %{} do %>
<div class="flex items-start gap-2 p-3 bg-base-200 rounded">
<div class="flex-shrink-0 pt-2">
<span class="font-mono text-sm font-semibold">{"{{#{name}}}"}</span>
</div>
<div class="flex-1">
<input
type="text"
value={description}
placeholder={gettext("Description for this variable...")}
class="input input-sm input-bordered w-full"
phx-change="update_variable_description"
phx-value-name={name}
phx-debounce="300"
/>
<input
type="hidden"
name={"email_template[variables][#{name}]"}
value={description}
/>
</div>
<button
type="button"
phx-click="remove_variable"
phx-value-name={name}
class="btn btn-xs btn-ghost text-error mt-1"
title={gettext("Remove variable")}
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
<% end %>
</div>
<% else %>
<div class="text-center py-8 text-base-content/60">
<.icon name="hero-code-bracket" class="w-12 h-12 mx-auto mb-3 opacity-30" />
<div class="font-medium mb-2">{gettext("No variables found in template")}</div>
<div class="text-sm">
Use {"{{variable_name}}"} syntax in subject or body to add dynamic variables.
</div>
<div class="text-sm mt-1">
Example: {"{{user_name}}"}, {"{{email}}"}, {"{{url}}"}
</div>
</div>
<% end %>
</div>
</div>
<%!-- Action Buttons --%>
<div class="flex flex-wrap justify-between gap-2">
<div class="flex flex-wrap gap-2">
<%!-- Test Send button (available in both new and edit modes) --%>
<button
type="button"
phx-click="show_test_modal"
class="btn btn-outline btn-secondary"
>
<.icon name="hero-envelope" class="w-4 h-4 mr-1" /> {gettext("Test Send")}
</button>
<%!-- Save as Draft button (only in new mode) --%>
<%= if @mode == :new do %>
<button
type="submit"
name="save_as"
value="draft"
class="btn btn-outline btn-warning"
>
<.icon name="hero-document" class="w-4 h-4 mr-1" /> {gettext("Save as Draft")}
</button>
<% end %>
</div>
<div class="flex flex-wrap gap-2">
<.link
navigate={Routes.path("/admin/emails/templates")}
class="btn btn-ghost"
>
{gettext("Cancel")}
</.link>
<button
type="submit"
class={[
"btn btn-primary",
@saving && "loading"
]}
disabled={@saving}
>
<%= if @saving do %>
{gettext("Saving...")}
<% else %>
{if @mode == :new,
do: gettext("Create Template"),
else: gettext("Update Template")}
<% end %>
</button>
</div>
</div>
</.form>
</div>
<%!-- Preview Panel --%>
<div class="space-y-6">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h2 class="card-title text-lg">{gettext("Live Preview")}</h2>
<div class="btn-group">
<button
type="button"
phx-click="switch_preview"
phx-value-mode="html"
class={[
"btn btn-sm",
@preview_mode == "html" && "btn-active"
]}
>
HTML
</button>
<button
type="button"
phx-click="switch_preview"
phx-value-mode="text"
class={[
"btn btn-sm",
@preview_mode == "text" && "btn-active"
]}
>
Text
</button>
</div>
</div>
<%!-- Subject Preview — locale-aware --%>
<% preview_subject =
PhoenixKit.Modules.Emails.Template.get_translation(
Ecto.Changeset.get_field(@changeset, :subject) || %{},
@current_editor_locale
) %>
<div class="mb-4">
<div class="text-sm font-medium text-base-content/70 mb-1">
{gettext("Subject (%{locale}):", locale: String.upcase(@current_editor_locale))}
</div>
<div class="p-2 bg-base-200 rounded text-sm">
{if preview_subject == "", do: gettext("No subject"), else: preview_subject}
</div>
</div>
<%!-- Body Preview — locale-aware --%>
<% preview_html =
PhoenixKit.Modules.Emails.Template.get_translation(
Ecto.Changeset.get_field(@changeset, :html_body) || %{},
@current_editor_locale
) %>
<% preview_text =
PhoenixKit.Modules.Emails.Template.get_translation(
Ecto.Changeset.get_field(@changeset, :text_body) || %{},
@current_editor_locale
) %>
<div class="border rounded-lg overflow-hidden">
<%= if @preview_mode == "html" do %>
<%= if preview_html != "" do %>
<iframe
id="email-preview-iframe"
sandbox="allow-same-origin"
class="w-full border-0 bg-white"
style="min-height: 400px; height: auto;"
srcdoc={preview_html}
phx-update="ignore"
onload="this.style.height = (this.contentWindow.document.body.scrollHeight + 40) + 'px';"
>
</iframe>
<% else %>
<div class="bg-white p-4 min-h-96">
<div class="text-gray-500 italic">{gettext("No HTML content")}</div>
</div>
<% end %>
<% else %>
<div class="bg-gray-50 p-4 min-h-96 font-mono text-sm whitespace-pre-wrap">
<%= if preview_text != "" do %>
{preview_text}
<% else %>
<div class="text-gray-500 italic not-italic">
{gettext("No text content")}
</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</div>
</div>
<%!-- Test Email Modal --%>
<div
:if={@show_test_modal}
class="modal modal-open"
phx-click-away="hide_test_modal"
>
<div class="modal-box w-full max-w-2xl">
<h3 class="font-bold text-lg mb-4">{gettext("Send Test Email")}</h3>
<.form
for={%{}}
phx-submit="send_test"
phx-change="validate_test"
class="space-y-4"
>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Recipient Email")}</span>
</label>
<input
type="email"
name="test[recipient]"
value={@test_form[:recipient] || ""}
placeholder="admin@example.com"
class={[
"input input-bordered w-full",
@test_form[:errors][:recipient] && "input-error"
]}
required
/>
<%= if @test_form[:errors][:recipient] do %>
<label class="label">
<span class="label-text-alt text-error">
{@test_form[:errors][:recipient]}
</span>
</label>
<% end %>
</div>
<%= if length(@extracted_variables) > 0 do %>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Sample Variable Values")}</span>
</label>
<div class="space-y-2">
<%= for variable <- @extracted_variables do %>
<div class="grid grid-cols-3 gap-2 items-center">
<label class="text-sm font-mono">{"{{#{variable}}}"}</label>
<div class="col-span-2">
<input
type="text"
name={"test[sample_variables][#{variable}]"}
value={@test_form[:sample_variables][variable] || ""}
placeholder={"Sample #{variable}"}
class="input input-bordered input-sm w-full"
/>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<div class="alert alert-info">
<.icon name="hero-information-circle" class="w-5 h-5" />
<div class="text-sm">
{gettext(
"This will send a test email using the current template content with the sample variables provided above."
)}
</div>
</div>
<div class="modal-action">
<button
type="button"
phx-click="hide_test_modal"
class="btn btn-ghost"
>
{gettext("Cancel")}
</button>
<button
type="submit"
class={[
"btn btn-primary",
@test_sending && "loading"
]}
disabled={@test_sending}
>
<%= if @test_sending do %>
{gettext("Sending...")}
<% else %>
{gettext("Send Test Email")}
<% end %>
</button>
</div>
</.form>
</div>
</div>
</div>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>