Current section
Files
Jump to
Current section
Files
lib/phoenix_kit/modules/emails/web/emails.html.heex
<div class="container flex-col mx-auto px-4 py-6">
<.admin_page_header
back={Routes.path("/admin")}
title={gettext("Emails")}
subtitle={gettext("Monitor and track all outgoing emails")}
/>
<%!-- Statistics Summary --%>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<PhoenixKitWeb.Components.Core.StatCard.stat_card
compact={true}
rounded="xl"
value={@stats[:total_sent] || 0}
title={gettext("Total Sent")}
subtitle={gettext("Last 30 days")}
>
<:icon>
<.icon name="hero-envelope" class="w-5 h-5" />
</:icon>
</PhoenixKitWeb.Components.Core.StatCard.stat_card>
<PhoenixKitWeb.Components.Core.StatCard.stat_card
compact={true}
rounded="xl"
value={@stats[:delivered] || 0}
title={gettext("Delivered")}
subtitle={gettext("%{rate}% rate", rate: @stats[:delivery_rate] || 0)}
>
<:icon>
<.icon name="hero-check-circle" class="w-5 h-5" />
</:icon>
</PhoenixKitWeb.Components.Core.StatCard.stat_card>
<PhoenixKitWeb.Components.Core.StatCard.stat_card
compact={true}
rounded="xl"
value={@stats[:bounced] || 0}
title={gettext("Bounced")}
subtitle={gettext("%{rate}% rate", rate: @stats[:bounce_rate] || 0)}
>
<:icon>
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
</:icon>
</PhoenixKitWeb.Components.Core.StatCard.stat_card>
<PhoenixKitWeb.Components.Core.StatCard.stat_card
compact={true}
rounded="xl"
value={@stats[:opened] || 0}
title={gettext("Opened")}
subtitle={gettext("%{rate}% rate", rate: @stats[:open_rate] || 0)}
>
<:icon>
<.icon name="hero-envelope-open" class="w-5 h-5" />
</:icon>
</PhoenixKitWeb.Components.Core.StatCard.stat_card>
</div>
<%!-- Filters & Search --%>
<div class="bg-base-200 rounded-lg p-6 mb-6">
<.form for={%{}} phx-change="filter" class="space-y-4">
<%!-- Search Bar --%>
<div class="flex gap-2 items-center">
<input
type="text"
name="search[query]"
value={@filters.search}
placeholder={gettext("Search by recipient, subject, or campaign...")}
class="input input-bordered flex-1"
phx-debounce="300"
/>
<button type="button" phx-click="clear_filters" class="btn btn-ghost btn-sm">
{gettext("Clear")}
</button>
</div>
<%!-- Filter Row --%>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<%!-- Status Filter --%>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Status")}</span>
</label>
<label class="select">
<select name="filter[status]">
<option value="">{gettext("All Statuses")}</option>
<option value="queued" selected={@filters.status == "queued"}>
{gettext("Queued")}
</option>
<option value="sent" selected={@filters.status == "sent"}>{gettext("Sent")}</option>
<option value="delivered" selected={@filters.status == "delivered"}>
{gettext("Delivered")}
</option>
<option value="opened" selected={@filters.status == "opened"}>
{gettext("Opened")}
</option>
<option value="clicked" selected={@filters.status == "clicked"}>
{gettext("Clicked")}
</option>
<option value="bounced" selected={@filters.status == "bounced"}>
{gettext("Bounced")}
</option>
<option value="hard_bounced" selected={@filters.status == "hard_bounced"}>
{gettext("Hard Bounced")}
</option>
<option value="soft_bounced" selected={@filters.status == "soft_bounced"}>
{gettext("Soft Bounced")}
</option>
<option value="rejected" selected={@filters.status == "rejected"}>
{gettext("Rejected")}
</option>
<option value="delayed" selected={@filters.status == "delayed"}>
{gettext("Delayed")}
</option>
<option value="complaint" selected={@filters.status == "complaint"}>
{gettext("Complaint")}
</option>
<option value="failed" selected={@filters.status == "failed"}>
{gettext("Failed")}
</option>
</select>
</label>
</div>
<%!-- Category Filter --%>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Category")}</span>
</label>
<label class="select">
<select name="filter[category]">
<option value="">{gettext("All Categories")}</option>
<option value="system" selected={@filters.category == "system"}>
{gettext("System")}
</option>
<option value="transactional" selected={@filters.category == "transactional"}>
{gettext("Transactional")}
</option>
<option value="marketing" selected={@filters.category == "marketing"}>
{gettext("Marketing")}
</option>
<option value="notification" selected={@filters.category == "notification"}>
{gettext("Notification")}
</option>
<option value="newsletters" selected={@filters.category == "newsletters"}>
{gettext("Newsletters")}
</option>
</select>
</label>
</div>
<%!-- Source Module Filter --%>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Source Module")}</span>
</label>
<label class="select">
<select name="filter[source_module]">
<option value="">{gettext("All Modules")}</option>
<option value="users" selected={@filters.source_module == "users"}>
{gettext("Users")}
</option>
<option value="billing" selected={@filters.source_module == "billing"}>
{gettext("Billing")}
</option>
<option value="publishing" selected={@filters.source_module == "publishing"}>
{gettext("Publishing")}
</option>
<option value="entities" selected={@filters.source_module == "entities"}>
{gettext("Entities")}
</option>
<option value="admin" selected={@filters.source_module == "admin"}>
{gettext("Admin")}
</option>
<option value="custom" selected={@filters.source_module == "custom"}>
{gettext("Custom")}
</option>
</select>
</label>
</div>
</div>
<%!-- Second Filter Row: Date Range --%>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<%!-- Date Range --%>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("From Date")}</span>
</label>
<input
type="date"
name="filter[from_date]"
value={@filters.from_date}
class="input input-bordered"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("To Date")}</span>
</label>
<input
type="date"
name="filter[to_date]"
value={@filters.to_date}
class="input input-bordered"
/>
</div>
</div>
<%!-- Quick Actions Row --%>
<div class="w-full">
<label class="label">
<span class="label-text">{gettext("Quick Actions")}</span>
</label>
<div class="flex flex-wrap gap-2">
<button
type="button"
phx-click="show_column_modal"
class="btn btn-info btn-sm"
title={gettext("Customize columns")}
>
<.icon name="hero-view-columns" class="h-5 w-5" />
<span class="hidden sm:inline">{gettext("Columns")}</span>
</button>
<.link
navigate={Routes.path("/admin/emails/templates")}
class="btn btn-outline btn-secondary btn-sm"
>
<.icon name="hero-document-text" class="w-4 h-4" />
<span class="hidden sm:inline ml-1">{gettext("Templates")}</span>
</.link>
<button
type="button"
phx-click="show_test_email_modal"
class="btn btn-primary btn-sm"
>
<.icon name="hero-envelope" class="w-4 h-4" />
<span class="hidden sm:inline ml-1">{gettext("Send Test")}</span>
</button>
<.link
href={build_export_url(@filters)}
target="_blank"
class="btn btn-outline btn-sm"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4" />
<span class="hidden sm:inline ml-1">{gettext("Export CSV")}</span>
</.link>
<button type="button" phx-click="refresh" class="btn btn-outline btn-sm">
<.icon name="hero-arrow-path" class="w-4 h-4" />
<span class="hidden sm:inline ml-1">{gettext("Refresh")}</span>
</button>
</div>
</div>
</.form>
</div>
<%!-- Emails Table --%>
<div class="bg-base-100">
<%= if @loading do %>
<div class="flex justify-center items-center h-32">
<span class="loading loading-spinner loading-md"></span>
<span class="ml-2">{gettext("Loading emails...")}</span>
</div>
<% else %>
<%= if length(@logs) > 0 do %>
<.table_default
id="emails-table"
variant="zebra"
class="w-full"
toggleable={true}
items={@logs}
card_title={fn log -> log.to end}
card_fields={
fn log ->
[
%{label: gettext("Subject"), value: log.subject || gettext("(no subject)")},
%{label: gettext("Status"), value: log.status || "—"},
%{label: gettext("Template"), value: log.template_name || "—"}
]
end
}
>
<:card_actions :let={log}>
<.table_row_menu id={"email-card-menu-#{log.uuid}"} label={gettext("Email actions")}>
<.table_row_menu_button
phx-click="view_details"
phx-value-uuid={log.uuid}
icon="hero-eye"
label={gettext("View")}
/>
</.table_row_menu>
</:card_actions>
<.table_default_header>
<.table_default_row>
<%= for field <- @selected_columns do %>
<% column = Enum.find(@available_columns, fn col -> col.field == field end) %>
<%= if column do %>
<.table_default_header_cell>
<%= if field == "status" do %>
<div class="flex items-center gap-1">
<span>{column.label}</span>
<div class="dropdown dropdown-hover dropdown-bottom dropdown-end">
<div tabindex="0" role="button">
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/50 hover:text-base-content cursor-help"
/>
</div>
<div
tabindex="0"
class="dropdown-content z-[1] p-3 shadow-lg bg-base-100 rounded-box w-40 border border-base-300 text-base-content"
>
<div class="text-xs font-medium mb-2">{gettext("Legend:")}</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<span class="badge badge-ghost badge-xs h-auto"></span>
<span class="text-xs">{gettext("Queued")}</span>
</div>
<div class="flex items-center gap-2">
<span class="badge badge-info badge-xs h-auto"></span>
<span class="text-xs">{gettext("Sent")}</span>
</div>
<div class="flex items-center gap-2">
<span class="badge badge-success badge-xs h-auto"></span>
<span class="text-xs">{gettext("Delivered")}</span>
</div>
<div class="flex items-center gap-2">
<span class="badge badge-primary badge-xs h-auto"></span>
<span class="text-xs">{gettext("Opened")}</span>
</div>
<div class="flex items-center gap-2">
<span class="badge badge-secondary badge-xs h-auto"></span>
<span class="text-xs">{gettext("Clicked")}</span>
</div>
<div class="flex items-center gap-2">
<span class="badge badge-warning badge-xs h-auto"></span>
<span class="text-xs">{gettext("Delayed")}</span>
</div>
<div class="flex items-center gap-2">
<span class="badge badge-error badge-xs h-auto"></span>
<span class="text-xs">{gettext("Error")}</span>
</div>
</div>
</div>
</div>
</div>
<% else %>
<%= if field == "details" do %>
<div class="flex items-center gap-1">
<span>{column.label}</span>
<div class="dropdown dropdown-hover dropdown-bottom dropdown-end">
<div tabindex="0" role="button">
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/50 hover:text-base-content cursor-help"
/>
</div>
<div
tabindex="0"
class="dropdown-content z-[1] p-3 shadow-lg bg-base-100 rounded-box w-48 border border-base-300 text-base-content"
>
<div class="text-xs font-medium mb-2">{gettext("Legend:")}</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-accent"></span>
<span class="text-xs">{gettext("Source Module")}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full bg-primary"></span>
<span class="text-xs">{gettext("Campaign")}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full border-2 border-base-content/30 bg-transparent">
</span>
<span class="text-xs">{gettext("Template")}</span>
</div>
</div>
</div>
</div>
</div>
<% else %>
{column.label}
<% end %>
<% end %>
</.table_default_header_cell>
<% end %>
<% end %>
</.table_default_row>
</.table_default_header>
<.table_default_body>
<%= for log <- @logs do %>
<.table_default_row>
<%= for field <- @selected_columns do %>
<.table_default_cell>
<%= case field do %>
<% "to" -> %>
<div class="space-y-1">
<div class="font-medium text-sm">{log.to}</div>
<%= if log.user_uuid do %>
<div class="badge badge-ghost badge-xs h-auto">
{gettext("User")} {log.user_uuid}
</div>
<% end %>
</div>
<% "subject" -> %>
<div class="text-sm truncate" title={log.subject}>
{log.subject || gettext("(no subject)")}
</div>
<% "status" -> %>
<.email_activity_badges log={log} />
<% "details" -> %>
<div class="flex flex-wrap gap-1">
<%!-- Source module badge (accent - primary info) --%>
<% source_module =
is_map(log.message_tags) &&
Map.get(log.message_tags, "source_module") %>
<%= if source_module do %>
<div class="badge badge-accent badge-xs h-auto">
{source_module}
</div>
<% end %>
<%!-- Campaign badge (primary) --%>
<%= if log.campaign_id do %>
<div class="badge badge-primary badge-xs h-auto">
{log.campaign_id}
</div>
<% end %>
<%!-- Template name badge (outline) --%>
<%= if log.template_name do %>
<div class="badge badge-outline badge-xs h-auto">
{log.template_name}
</div>
<% end %>
<% has_details =
source_module || log.campaign_id || log.template_name %>
<%= if !has_details do %>
<span class="text-xs text-base-content/50">—</span>
<% end %>
</div>
<% "actions" -> %>
<.table_row_menu
id={"email-menu-#{log.uuid}"}
label={gettext("Email actions")}
>
<.table_row_menu_button
phx-click="view_details"
phx-value-uuid={log.uuid}
icon="hero-eye"
label={gettext("View")}
/>
</.table_row_menu>
<% _ -> %>
<span class="text-xs text-base-content/50">—</span>
<% end %>
</.table_default_cell>
<% end %>
</.table_default_row>
<% end %>
</.table_default_body>
</.table_default>
<% else %>
<%!-- Empty state --%>
<div class="text-center py-12">
<.icon name="hero-envelope" class="h-16 w-16 mx-auto text-base-content/40 mb-4" />
<h3 class="text-lg font-medium text-base-content mb-2">
{gettext("No emails found")}
</h3>
<p class="text-base-content/70 mb-4">
{gettext("No emails found matching your criteria")}
</p>
</div>
<% end %>
<%!-- Pagination --%>
<%= if @total_count > @per_page do %>
<.pagination
current_page={@page}
total_pages={@total_pages}
base_path="/admin/emails"
params={
%{
"search" => @filters.search,
"status" => @filters.status,
"category" => @filters.category,
"source_module" => @filters.source_module,
"campaign_id" => @filters.campaign_id,
"from_date" => @filters.from_date,
"to_date" => @filters.to_date,
"per_page" => @per_page
}
}
/>
<% end %>
<% end %>
</div>
<%!-- Test Email Modal --%>
<div
:if={@show_test_email_modal}
class="modal modal-open"
phx-click-away="hide_test_email_modal"
>
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">{gettext("Send Test Email")}</h3>
<p class="text-sm text-base-content/70 mb-4">
{gettext(
"Send a test email to verify that email is working correctly. The test email will include test links."
)}
</p>
<.form
for={%{}}
phx-submit="send_test_email"
phx-change="validate_test_email"
class="space-y-4"
>
<div class="form-control">
<label class="label">
<span class="label-text">{gettext("Recipient Email Address")}</span>
</label>
<input
type="email"
name="test_email[recipient]"
value={@test_email_form[:recipient] || ""}
placeholder="admin@example.com"
class={[
"input input-bordered w-full",
@test_email_form[:errors][:recipient] && "input-error"
]}
required
/>
<%= if @test_email_form[:errors][:recipient] do %>
<label class="label">
<span class="label-text-alt text-error">
{@test_email_form[:errors][:recipient]}
</span>
</label>
<% end %>
</div>
<div class="alert alert-info">
<.icon name="hero-information-circle" class="w-5 h-5" />
<div class="text-sm">
<div class="font-semibold">{gettext("This test email will:")}</div>
<ul class="mt-1 list-disc list-inside">
<li>{gettext("Verify AWS SES configuration (if enabled)")}</li>
<li>{gettext("Test email delivery management")}</li>
<li>{gettext("Include trackable links for click testing")}</li>
<li>{gettext("Appear in your emails with \"TEST\" campaign")}</li>
</ul>
</div>
</div>
<div class="modal-action">
<button
type="button"
phx-click="hide_test_email_modal"
class="btn btn-ghost"
>
{gettext("Cancel")}
</button>
<button
type="submit"
class={[
"btn btn-primary",
@test_email_sending && "loading"
]}
disabled={@test_email_sending}
>
<%= if @test_email_sending do %>
{gettext("Sending...")}
<% else %>
{gettext("Send Test Email")}
<% end %>
</button>
</div>
</.form>
</div>
</div>
<%!-- Column Selection Modal --%>
<%= if @show_column_modal do %>
<div class="modal modal-open" phx-click-away="hide_column_modal">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-xl mb-4">{gettext("Customize Table Columns")}</h3>
<p class="text-base-content/70 mb-6">
{gettext(
"Select which columns to display in the emails table. Required columns (Email, Subject, Status, Actions) cannot be hidden."
)}
</p>
<div class="space-y-2 max-h-96 overflow-y-auto">
<%= for column <- @available_columns do %>
<div class="flex items-center p-3 rounded-lg hover:bg-base-200 transition-colors">
<input
type="checkbox"
class="checkbox checkbox-primary"
id={"column-#{column.field}"}
checked={column.field in @selected_columns}
disabled={column.required}
phx-click="toggle_column"
phx-value-field={column.field}
/>
<label for={"column-#{column.field}"} class="ml-3 flex-1 cursor-pointer">
<div class="font-medium text-sm text-base-content">
{column.label}
</div>
<%= if column.required do %>
<div class="text-xs text-base-content/50">
{gettext("Required column")}
</div>
<% end %>
</label>
<%= if column.required do %>
<div class="badge badge-sm badge-ghost">{gettext("Required")}</div>
<% end %>
</div>
<% end %>
</div>
<div class="modal-action">
<button
type="button"
phx-click="reset_columns"
class="btn btn-outline"
>
{gettext("Reset to Default")}
</button>
<button
type="button"
phx-click="hide_column_modal"
class="btn btn-primary"
>
{gettext("Close")}
</button>
</div>
</div>
</div>
<% end %>
</div>