Packages

phoenix_kit

1.7.64
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
phoenix_kit lib modules emails web metrics.html.heex
Raw

lib/modules/emails/web/metrics.html.heex

<PhoenixKitWeb.Components.LayoutWrapper.app_layout
flash={@flash}
phoenix_kit_current_scope={assigns[:phoenix_kit_current_scope]}
page_title="Email Analytics"
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")}
title="Email Dashboard"
subtitle="Comprehensive email performance metrics and insights"
/>
<%!-- Controls Section --%>
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between mb-6 gap-4">
<%!-- Period Selection --%>
<div class="flex flex-wrap gap-2">
<button
class={[
"btn btn-sm",
(@period == :last_24_hours && "btn-primary") || "btn-outline"
]}
phx-click="change_period"
phx-value-period="last_24_hours"
>
24 Hours
</button>
<button
class={[
"btn btn-sm",
(@period == :last_7_days && "btn-primary") || "btn-outline"
]}
phx-click="change_period"
phx-value-period="last_7_days"
>
7 Days
</button>
<button
class={[
"btn btn-sm",
(@period == :last_30_days && "btn-primary") || "btn-outline"
]}
phx-click="change_period"
phx-value-period="last_30_days"
>
30 Days
</button>
<button
class={[
"btn btn-sm",
(@period == :last_90_days && "btn-primary") || "btn-outline"
]}
phx-click="change_period"
phx-value-period="last_90_days"
>
90 Days
</button>
<button
class="btn btn-sm btn-outline"
phx-click="toggle_custom_range"
>
Custom Range
</button>
</div>
<%!-- Action Buttons --%>
<div class="flex gap-2">
<button
class="btn btn-sm btn-outline"
phx-click="refresh"
disabled={@loading}
>
<%= if @loading do %>
<span class="loading loading-spinner loading-xs"></span>
<% else %>
<PhoenixKitWeb.Components.Core.Icons.icon_refresh class="w-4 h-4" />
<% end %>
Refresh
</button>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-sm btn-outline">
<PhoenixKitWeb.Components.Core.Icons.icon_download class="w-4 h-4" /> Export
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"
>
<li>
<a phx-click="export_metrics" phx-value-format="csv">
Export as CSV
</a>
</li>
<li>
<a phx-click="export_metrics" phx-value-format="json">
Export as JSON
</a>
</li>
</ul>
</div>
</div>
</div>
<%!-- Custom Date Range Modal --%>
<%= if @custom_range do %>
<div class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">Select Custom Date Range</h3>
<form phx-submit="apply_custom_range">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">
<span class="label-text">Start Date</span>
</label>
<input
type="date"
name="start_date"
class="input input-bordered w-full"
max={Date.utc_today() |> Date.to_iso8601()}
required
/>
</div>
<div>
<label class="label">
<span class="label-text">End Date</span>
</label>
<input
type="date"
name="end_date"
class="input input-bordered w-full"
max={Date.utc_today() |> Date.to_iso8601()}
required
/>
</div>
</div>
<div class="modal-action">
<button type="submit" class="btn btn-primary">Apply</button>
<button type="button" class="btn" phx-click="toggle_custom_range">Cancel</button>
</div>
</form>
</div>
</div>
<% end %>
<%!-- Loading State --%>
<%= if @loading do %>
<div class="flex justify-center items-center h-32">
<span class="loading loading-spinner loading-lg"></span>
<span class="ml-3 text-lg">Loading analytics data...</span>
</div>
<% else %>
<%!-- KPI Cards --%>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<%!-- Total Sent --%>
<div class="card bg-primary text-primary-content shadow-sm">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-primary-content/70 text-sm">Total Sent</p>
<p class="text-2xl font-bold">{UtilsNumber.format(@metrics.total_sent || 0)}</p>
</div>
<div class="text-primary-content/70">
<PhoenixKitWeb.Components.Core.Icons.icon_email class="w-8 h-8" />
</div>
</div>
</div>
</div>
<%!-- Delivery Rate --%>
<div class="card bg-success text-success-content shadow-sm">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-success-content/70 text-sm">Delivery Rate</p>
<p class="text-2xl font-bold">
{UtilsNumber.format_percentage(@metrics.delivery_rate || 0)}
</p>
</div>
<div class="text-success-content/70">
<PhoenixKitWeb.Components.Core.Icons.icon_check class="w-8 h-8" />
</div>
</div>
</div>
</div>
<%!-- Bounce Rate --%>
<div class="card bg-warning text-warning-content shadow-sm">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-warning-content/70 text-sm">Bounce Rate</p>
<p class="text-2xl font-bold">
{UtilsNumber.format_percentage(@metrics.bounce_rate || 0)}
</p>
</div>
<div class="text-warning-content/70">
<PhoenixKitWeb.Components.Core.Icons.icon_warning_filled class="w-8 h-8" />
</div>
</div>
</div>
</div>
<%!-- Open Rate --%>
<div class="card bg-info text-info-content shadow-sm">
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-info-content/70 text-sm">Open Rate</p>
<p class="text-2xl font-bold">
{UtilsNumber.format_percentage(@metrics.open_rate || 0)}
</p>
</div>
<div class="text-info-content/70">
<PhoenixKitWeb.Components.Core.Icons.icon_eye class="w-8 h-8" />
</div>
</div>
</div>
</div>
</div>
<%!-- Charts Section --%>
<div
class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"
id="email-charts-container"
phx-hook="EmailCharts"
>
<%!-- Delivery Trends Chart --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Delivery Trends</h2>
<div
class="w-full h-64"
id="delivery-chart-container"
data-chart-data={Jason.encode!(Map.get(@charts_data, :delivery_trend, %{}))}
>
<canvas id="delivery-trend-chart" width="400" height="200" phx-update="ignore">
</canvas>
</div>
</div>
</div>
<%!-- Engagement Chart --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Engagement Metrics</h2>
<div
class="w-full h-64"
id="engagement-chart-container"
data-chart-data={Jason.encode!(Map.get(@charts_data, :engagement, %{}))}
>
<canvas id="engagement-chart" width="400" height="200" phx-update="ignore"></canvas>
</div>
</div>
</div>
</div>
<%!-- Additional Analytics --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<%!-- Provider Performance --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">Provider Performance</h2>
<%= if @metrics.by_provider && length(Map.keys(@metrics.by_provider)) > 0 do %>
<div class="overflow-x-auto">
<.table_default size="sm">
<.table_default_header>
<.table_default_row>
<.table_default_header_cell>Provider</.table_default_header_cell>
<.table_default_header_cell>Sent</.table_default_header_cell>
<.table_default_header_cell>Delivery Rate</.table_default_header_cell>
<.table_default_header_cell>Bounce Rate</.table_default_header_cell>
</.table_default_row>
</.table_default_header>
<.table_default_body>
<%= for {provider, stats} <- @metrics.by_provider do %>
<.table_default_row>
<.table_default_cell class="font-medium">{provider}</.table_default_cell>
<.table_default_cell>
{UtilsNumber.format(stats.total_sent || 0)}
</.table_default_cell>
<.table_default_cell>
<span class={[
"badge badge-sm h-auto",
((stats.delivery_rate || 0) >= 95 && "badge-success") ||
((stats.delivery_rate || 0) >= 85 && "badge-warning") ||
"badge-error"
]}>
{UtilsNumber.format_percentage(stats.delivery_rate || 0)}
</span>
</.table_default_cell>
<.table_default_cell>
{UtilsNumber.format_percentage(stats.bounce_rate || 0)}
</.table_default_cell>
</.table_default_row>
<% end %>
</.table_default_body>
</.table_default>
</div>
<% else %>
<div class="text-center text-base-content/50 py-8">
No provider data available
</div>
<% end %>
</div>
</div>
<%!-- Recent Activity --%>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-lg mb-4">System Status</h2>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm">Email</span>
<span class="badge badge-success">Active</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Last Updated</span>
<span class="text-sm text-base-content/70">
{UtilsDate.format_datetime_with_user_format(@last_updated)}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Total Emails Today</span>
<span class="text-sm font-medium">
{UtilsNumber.format(@metrics.today_count || 0)}
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Data Retention</span>
<span class="text-sm text-base-content/70">
{PhoenixKit.Modules.Emails.get_retention_days()} days
</span>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
<%!-- Chart.js CDN and Enhanced Chart System --%>
<script src="https://cdn.jsdelivr.net/npm/chart.js">
</script>
<script>
(() => {
// Enhanced Chart Manager for PhoenixKit with retry and buffering
const PhoenixKitCharts = {
deliveryChart: null,
engagementChart: null,
isInitialized: false,
initAttempts: 0,
maxInitAttempts: 5,
bufferedData: null,
chartJsLoaded: false,
// Wait for Chart.js to be available
waitForChartJs() {
return new Promise((resolve) => {
if (typeof Chart !== 'undefined') {
this.chartJsLoaded = true;
resolve();
return;
}
let attempts = 0;
const checkInterval = setInterval(() => {
attempts++;
if (typeof Chart !== 'undefined') {
this.chartJsLoaded = true;
clearInterval(checkInterval);
resolve();
} else if (attempts > 50) { // 5 seconds max wait
clearInterval(checkInterval);
console.error('Chart.js failed to load');
resolve(); // Resolve anyway to prevent hanging
}
}, 100);
});
},
async initializeCharts() {
// Wait for Chart.js library to load
await this.waitForChartJs();
if (!this.chartJsLoaded) {
console.error('Cannot initialize charts: Chart.js not loaded');
return false;
}
// Check if elements exist
const deliveryCanvas = document.getElementById('delivery-trend-chart');
const engagementCanvas = document.getElementById('engagement-chart');
if (!deliveryCanvas || !engagementCanvas) {
console.log('Chart elements not found, will retry...');
return false;
}
try {
this.initDeliveryChart();
this.initEngagementChart();
this.isInitialized = true;
// Apply buffered data if exists
if (this.bufferedData) {
console.log('Applying buffered chart data');
this.updateCharts(this.bufferedData);
this.bufferedData = null;
}
return true;
} catch (error) {
console.error('Error initializing charts:', error);
this.isInitialized = false;
return false;
}
},
initDeliveryChart() {
const container = document.getElementById('delivery-chart-container');
const canvas = document.getElementById('delivery-trend-chart');
if (!canvas || !container) {
throw new Error('Delivery chart elements not found');
}
// Destroy existing chart
if (this.deliveryChart) {
this.deliveryChart.destroy();
this.deliveryChart = null;
}
// Get data from HTML attribute
let chartData = {};
try {
const dataAttr = container.getAttribute('data-chart-data');
if (dataAttr) {
chartData = JSON.parse(dataAttr);
console.log('Delivery chart data loaded from attribute');
}
} catch (e) {
console.warn('Failed to parse delivery chart data:', e);
}
// Create chart with data or defaults
const hasData = chartData.labels && chartData.labels.length > 0;
this.deliveryChart = new Chart(canvas, {
type: 'line',
data: hasData ? chartData : {
labels: [],
datasets: [
{
label: "Delivered",
data: [],
borderColor: "rgb(34, 197, 94)",
backgroundColor: "rgba(34, 197, 94, 0.1)",
tension: 0.1,
fill: true
},
{
label: "Bounced",
data: [],
borderColor: "rgb(239, 68, 68)",
backgroundColor: "rgba(239, 68, 68, 0.1)",
tension: 0.1,
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: hasData ? 750 : 0
},
scales: {
y: {
beginAtZero: true,
grid: { color: 'rgba(0, 0, 0, 0.1)' }
},
x: {
grid: { color: 'rgba(0, 0, 0, 0.1)' }
}
},
plugins: {
legend: { position: 'top' },
tooltip: {
mode: 'index',
intersect: false,
}
}
}
});
},
initEngagementChart() {
const container = document.getElementById('engagement-chart-container');
const canvas = document.getElementById('engagement-chart');
if (!canvas || !container) {
throw new Error('Engagement chart elements not found');
}
// Destroy existing chart
if (this.engagementChart) {
this.engagementChart.destroy();
this.engagementChart = null;
}
// Get data from HTML attribute
let chartData = {};
try {
const dataAttr = container.getAttribute('data-chart-data');
if (dataAttr) {
chartData = JSON.parse(dataAttr);
console.log('Engagement chart data loaded from attribute');
}
} catch (e) {
console.warn('Failed to parse engagement chart data:', e);
}
// Create chart with data or defaults
const hasData = chartData.labels && chartData.labels.length > 0;
this.engagementChart = new Chart(canvas, {
type: 'doughnut',
data: hasData ? chartData : {
labels: ["Opens", "Clicks", "Bounces", "Complaints"],
datasets: [{
data: [0, 0, 0, 0],
backgroundColor: [
"rgb(59, 130, 246)",
"rgb(34, 197, 94)",
"rgb(251, 191, 36)",
"rgb(239, 68, 68)"
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: {
duration: hasData ? 750 : 0
},
plugins: {
legend: { position: 'bottom' },
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed || 0;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
},
updateCharts(chartsData) {
// If not initialized yet, buffer the data
if (!this.isInitialized) {
console.log('Charts not initialized, buffering data');
this.bufferedData = chartsData;
this.tryInitWithRetry(); // Try to initialize
return;
}
// Update delivery chart
if (this.deliveryChart && chartsData.delivery_trend) {
this.deliveryChart.data = chartsData.delivery_trend;
this.deliveryChart.update();
}
// Update engagement chart
if (this.engagementChart && chartsData.engagement) {
this.engagementChart.data = chartsData.engagement;
this.engagementChart.update();
}
},
cleanup() {
this.isInitialized = false;
if (this.deliveryChart) {
this.deliveryChart.destroy();
this.deliveryChart = null;
}
if (this.engagementChart) {
this.engagementChart.destroy();
this.engagementChart = null;
}
},
async tryInitWithRetry() {
if (this.isInitialized || this.initAttempts >= this.maxInitAttempts) {
return;
}
this.initAttempts++;
console.log(`Chart initialization attempt ${this.initAttempts}/${this.maxInitAttempts}`);
const success = await this.initializeCharts();
if (!success && this.initAttempts < this.maxInitAttempts) {
// Exponential backoff: 100ms, 200ms, 400ms, 800ms, 1600ms
const delay = Math.min(100 * Math.pow(2, this.initAttempts - 1), 1600);
setTimeout(() => this.tryInitWithRetry(), delay);
} else if (success) {
console.log('Charts initialized successfully');
this.initAttempts = 0; // Reset for potential future reinits
}
}
};
// Make PhoenixKitCharts available globally for hooks
window.PhoenixKitCharts = PhoenixKitCharts;
// Listen for LiveView updates
window.addEventListener('phx:email-charts-update', (event) => {
if (event.detail && event.detail.charts) {
console.log('Received chart update event');
PhoenixKitCharts.updateCharts(event.detail.charts);
}
});
// Initialize charts when DOM is ready
const initCharts = () => {
PhoenixKitCharts.tryInitWithRetry();
};
// Handle different loading states
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCharts);
} else {
// DOM already loaded, but give LiveView time to render
setTimeout(initCharts, 50);
}
// Track if we're on the metrics page
let onMetricsPage = false;
// Check if we're on metrics page
const checkMetricsPage = () => {
return document.getElementById('email-charts-container') !== null;
};
// Handle LiveView navigation
window.addEventListener('phx:page-loading-start', () => {
PhoenixKitCharts.cleanup();
onMetricsPage = false;
});
window.addEventListener('phx:page-loading-stop', () => {
// Check if we landed on metrics page
setTimeout(() => {
onMetricsPage = checkMetricsPage();
if (onMetricsPage) {
console.log('Navigated to metrics page, initializing charts');
PhoenixKitCharts.initAttempts = 0;
initCharts();
}
}, 100);
});
// Reinitialize on visibility change (tab becomes active)
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !PhoenixKitCharts.isInitialized) {
console.log('Tab became visible, checking charts');
PhoenixKitCharts.tryInitWithRetry();
}
});
// Make available for debugging
window.PhoenixKitCharts = PhoenixKitCharts;
})();
</script>
</PhoenixKitWeb.Components.LayoutWrapper.app_layout>