From e7b0ef606479d5de71e95e4dbd4836b723d46873 Mon Sep 17 00:00:00 2001 From: timujeen Date: Wed, 18 Mar 2026 16:26:47 +0000 Subject: [PATCH 01/10] Add lastmod to sitemap group listings and homepage Group listing pages (/news, /legal) now use the most recent published post date as lastmod. Homepage (/) uses the latest date across all publishing entries. Other static pages use the sitemap generation date. --- lib/modules/sitemap/sources/publishing.ex | 16 +++++++++++++++- lib/modules/sitemap/sources/static.ex | 23 +++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/modules/sitemap/sources/publishing.ex b/lib/modules/sitemap/sources/publishing.ex index afa5f6b20..7f6a8fd32 100644 --- a/lib/modules/sitemap/sources/publishing.ex +++ b/lib/modules/sitemap/sources/publishing.ex @@ -158,7 +158,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do UrlEntry.new(%{ loc: url, - lastmod: nil, + lastmod: latest_post_date(slug, language), changefreq: "daily", priority: 0.7, title: name, @@ -384,6 +384,20 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Publishing do end end + # Latest lastmod among published posts in a group (for group listing pages) + defp latest_post_date(group_slug, language) do + post_language = language || get_default_language() + + Publishing.list_posts(group_slug, post_language) + |> Enum.filter(&published?/1) + |> Enum.reject(&excluded?/1) + |> Enum.map(&get_post_lastmod/1) + |> Enum.reject(&is_nil/1) + |> Enum.max(Date, fn -> nil end) + rescue + _ -> nil + end + defp get_post_lastmod(post) do case post do # Check metadata fields first (PhoenixKit Publishing uses published_at) diff --git a/lib/modules/sitemap/sources/static.ex b/lib/modules/sitemap/sources/static.ex index 636032418..45408c3fa 100644 --- a/lib/modules/sitemap/sources/static.ex +++ b/lib/modules/sitemap/sources/static.ex @@ -199,7 +199,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Static do UrlEntry.new(%{ loc: url, - lastmod: Date.utc_today(), + lastmod: static_lastmod(path), changefreq: Map.get(config, "changefreq", "weekly"), priority: Map.get(config, "priority", 0.5), title: Map.get(config, "title", path), @@ -224,7 +224,7 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Static do UrlEntry.new(%{ loc: url, - lastmod: Date.utc_today(), + lastmod: static_lastmod(path), changefreq: Map.get(config, "changefreq", "weekly"), priority: Map.get(config, "priority", 0.5), title: Map.get(config, "title", path), @@ -237,6 +237,25 @@ defmodule PhoenixKit.Modules.Sitemap.Sources.Static do end end + # For homepage, use the latest published content date across all publishing groups. + # For other static pages, use today's date as a reasonable approximation. + defp static_lastmod("/") do + alias PhoenixKit.Modules.Sitemap.Sources.Publishing + + if Code.ensure_loaded?(Publishing) and function_exported?(Publishing, :collect, 1) do + Publishing.collect([]) + |> Enum.map(& &1.lastmod) + |> Enum.reject(&is_nil/1) + |> Enum.max(Date, fn -> Date.utc_today() end) + else + Date.utc_today() + end + rescue + _ -> Date.utc_today() + end + + defp static_lastmod(_path), do: Date.utc_today() + # Resolve path from config: explicit path OR via RouteResolver defp resolve_path(%{"path" => path}) when is_binary(path) and path != "" do path From d20da8c7e7211753a2ecdab6026fa12a373a52a2 Mon Sep 17 00:00:00 2001 From: timujeen Date: Thu, 19 Mar 2026 22:02:52 +0000 Subject: [PATCH 02/10] Add email provider behaviour, refactor Mailer and UserNotifier - Add PhoenixKit.Email.Provider behaviour and DefaultProvider (no-op) - Refactor Mailer to use email_provider() instead of hard Emails aliases - Remove send_test_tracking_email from core (moves to emails package) - Refactor UserNotifier to use email_provider(), strip HTML fallbacks - Add Emails to ModuleRegistry known_external_packages - Remove email/sqs_polling queues from core Oban config, add add_oban_queue/3 - Fix pre-existing dialyzer ignore for Sync MapSet opaque types - Fix pre-existing credo alias ordering in shop catalog modules --- .dialyzer_ignore.exs | 8 +- lib/modules/emails/web/emails.ex | 5 +- lib/modules/shop/web/catalog_category.ex | 7 +- lib/modules/shop/web/catalog_product.ex | 7 +- lib/phoenix_kit/email/default_provider.ex | 54 +++ lib/phoenix_kit/email/provider.ex | 33 ++ lib/phoenix_kit/install/oban_config.ex | 113 +++--- lib/phoenix_kit/mailer.ex | 366 ++------------------ lib/phoenix_kit/module_registry.ex | 9 + lib/phoenix_kit/users/auth/user_notifier.ex | 283 ++------------- 10 files changed, 229 insertions(+), 656 deletions(-) create mode 100644 lib/phoenix_kit/email/default_provider.ex create mode 100644 lib/phoenix_kit/email/provider.ex diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 516b15f61..d125befc7 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -138,5 +138,11 @@ # ExUnit internal functions — false positives when test/support is compiled in MIX_ENV=test # Dialyzer cannot resolve ExUnit private macros expanded at compile time {"test/support/conn_case.ex", :unknown_function}, - {"test/support/data_case.ex", :unknown_function} + {"test/support/data_case.ex", :unknown_function}, + + # Sync connections_live - MapSet opaque type false positives in topo_sort/visit_node + # Same pattern as context_selector.ex - MapSet.t() opaque type through recursive functions + # Matches both standard Dialyxir format (call_without_opaque) and legacy format (opaque term) + ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*call_without_opaque/, + ~r/lib\/modules\/sync\/web\/connections_live\.ex:.*opaque term/ ] diff --git a/lib/modules/emails/web/emails.ex b/lib/modules/emails/web/emails.ex index 75534d64c..addece3a3 100644 --- a/lib/modules/emails/web/emails.ex +++ b/lib/modules/emails/web/emails.ex @@ -285,7 +285,10 @@ defmodule PhoenixKit.Modules.Emails.Web.Emails do @impl true def handle_info({:send_test_email, recipient}, socket) do - case PhoenixKit.Mailer.send_test_tracking_email(recipient) do + provider = + Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Email.DefaultProvider) + + case provider.send_test_tracking_email(recipient, nil) do {:ok, _email} -> Logger.info("Test email sent successfully", %{ recipient: recipient, diff --git a/lib/modules/shop/web/catalog_category.ex b/lib/modules/shop/web/catalog_category.ex index b2b1a3d26..70085b13d 100644 --- a/lib/modules/shop/web/catalog_category.ex +++ b/lib/modules/shop/web/catalog_category.ex @@ -15,6 +15,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do alias PhoenixKit.Modules.Shop.Web.Components.ShopLayouts alias PhoenixKit.Modules.Shop.Web.Helpers alias PhoenixKit.Settings + alias PhoenixKitWeb.AdminEditHelper alias PhoenixKit.Utils.Routes @impl true @@ -104,8 +105,10 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogCategory do :category_icon_mode, Settings.get_setting_cached("shop_category_icon_mode", "none") ) - |> assign(:admin_edit_url, Routes.path("/admin/shop/categories/#{category.uuid}/edit")) - |> assign(:admin_edit_label, "Edit Category") + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/shop/categories/#{category.uuid}/edit"), + "Edit Category" + ) {:ok, socket} end diff --git a/lib/modules/shop/web/catalog_product.ex b/lib/modules/shop/web/catalog_product.ex index 9042ad308..30dd40105 100644 --- a/lib/modules/shop/web/catalog_product.ex +++ b/lib/modules/shop/web/catalog_product.ex @@ -23,6 +23,7 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do alias PhoenixKit.Settings alias PhoenixKit.Utils.Date, as: UtilsDate alias PhoenixKit.Utils.Routes + alias PhoenixKitWeb.AdminEditHelper # Data URI placeholder for broken images - works without external file serving @placeholder_data_uri "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Crect width='400' height='400' fill='%23e5e7eb'/%3E%3Cg fill='%239ca3af'%3E%3Crect x='160' y='140' width='80' height='60' rx='4'/%3E%3Ccircle cx='180' cy='160' r='8'/%3E%3Cpath d='M160 190 l25-20 l15 15 l20-25 l20 30 v10 h-80 z'/%3E%3C/g%3E%3C/svg%3E" @@ -134,8 +135,10 @@ defmodule PhoenixKit.Modules.Shop.Web.CatalogProduct do :category_icon_mode, Settings.get_setting_cached("shop_category_icon_mode", "none") ) - |> assign(:admin_edit_url, Routes.path("/admin/shop/products/#{product.uuid}/edit")) - |> assign(:admin_edit_label, "Edit Product") + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/shop/products/#{product.uuid}/edit"), + "Edit Product" + ) {:ok, socket} end diff --git a/lib/phoenix_kit/email/default_provider.ex b/lib/phoenix_kit/email/default_provider.ex new file mode 100644 index 000000000..2b2497e90 --- /dev/null +++ b/lib/phoenix_kit/email/default_provider.ex @@ -0,0 +1,54 @@ +defmodule PhoenixKit.Email.DefaultProvider do + @moduledoc """ + No-op email provider. Used when phoenix_kit_emails package is not installed. + + - Interception: passes emails through unchanged, no tracking + - Templates: returns nil → triggers hardcoded fallbacks in Mailer + - AWS: returns empty/false → Mailer uses static config only + """ + @behaviour PhoenixKit.Email.Provider + + # Interception — pass through, no tracking + @impl true + def intercept_before_send(email, _opts), do: email + @impl true + def handle_after_send(_email, _result), do: :ok + + # Templates — nil triggers hardcoded fallback + @impl true + def get_active_template_by_name(_name), do: nil + @impl true + def render_template(_t, _v), do: %{subject: "", html_body: "", text_body: ""} + @impl true + def render_template(_t, _v, _l), do: %{subject: "", html_body: "", text_body: ""} + @impl true + def track_usage(_template), do: :ok + @impl true + def get_source_module(_template), do: nil + + # AWS — not configured without package + @impl true + def get_aws_region, do: "" + @impl true + def get_aws_access_key, do: "" + @impl true + def get_aws_secret_key, do: "" + @impl true + def aws_configured?, do: false + + # Test email — not supported without emails package + @impl true + def send_test_tracking_email(_recipient_email, _user_uuid), do: {:error, :not_supported} + + # Provider detection — basic mapping + @impl true + def adapter_to_provider_name(nil, default), do: default + def adapter_to_provider_name(Swoosh.Adapters.AmazonSES, _), do: "amazon_ses" + def adapter_to_provider_name(Swoosh.Adapters.Mailgun, _), do: "mailgun" + def adapter_to_provider_name(Swoosh.Adapters.Sendgrid, _), do: "sendgrid" + def adapter_to_provider_name(Swoosh.Adapters.SMTP, _), do: "smtp" + def adapter_to_provider_name(Swoosh.Adapters.Postmark, _), do: "postmark" + def adapter_to_provider_name(Swoosh.Adapters.Local, _), do: "local" + def adapter_to_provider_name(Swoosh.Adapters.Test, _), do: "test" + def adapter_to_provider_name(_adapter, default), do: default +end diff --git a/lib/phoenix_kit/email/provider.ex b/lib/phoenix_kit/email/provider.ex new file mode 100644 index 000000000..08163f6f8 --- /dev/null +++ b/lib/phoenix_kit/email/provider.ex @@ -0,0 +1,33 @@ +defmodule PhoenixKit.Email.Provider do + @moduledoc """ + Unified email provider behaviour. + + Covers interception (pre/post send hooks), DB templates, AWS config, + and provider detection. The emails package implements this fully. + The DefaultProvider is a no-op that passes emails through unchanged. + """ + + # Interception + @callback intercept_before_send(Swoosh.Email.t(), keyword()) :: Swoosh.Email.t() + @callback handle_after_send(Swoosh.Email.t(), {:ok, any()} | {:error, any()}) :: :ok + + # Templates + @callback get_active_template_by_name(String.t()) :: map() | nil + @callback render_template(map(), map()) :: map() + @callback render_template(map(), map(), String.t()) :: map() + @callback track_usage(map()) :: :ok + @callback get_source_module(map()) :: String.t() | nil + + # AWS config + @callback get_aws_region() :: String.t() + @callback get_aws_access_key() :: String.t() + @callback get_aws_secret_key() :: String.t() + @callback aws_configured?() :: boolean() + + # Provider detection + @callback adapter_to_provider_name(atom(), String.t()) :: String.t() + + # Test email (only supported by emails package) + @callback send_test_tracking_email(String.t(), String.t() | nil) :: + {:ok, Swoosh.Email.t()} | {:error, any()} +end diff --git a/lib/phoenix_kit/install/oban_config.ex b/lib/phoenix_kit/install/oban_config.ex index ad495d5f9..80a2d446e 100644 --- a/lib/phoenix_kit/install/oban_config.ex +++ b/lib/phoenix_kit/install/oban_config.ex @@ -15,7 +15,6 @@ defmodule PhoenixKit.Install.ObanConfig do @dialyzer {:nowarn_function, update_existing_oban_config: 3} @dialyzer {:nowarn_function, ensure_posts_queue: 2} @dialyzer {:nowarn_function, ensure_sitemap_queue: 2} - @dialyzer {:nowarn_function, ensure_sqs_polling_queue: 2} @dialyzer {:nowarn_function, ensure_sync_queue: 2} @dialyzer {:nowarn_function, ensure_shop_imports_queue: 2} @dialyzer {:nowarn_function, ensure_newsletters_delivery_queue: 2} @@ -89,6 +88,55 @@ defmodule PhoenixKit.Install.ObanConfig do _ -> false end + @doc """ + Adds an Oban queue to the parent app's config. + Called by external package installers (e.g., phoenix_kit_emails.install). + + Returns the igniter with the queue added, or unchanged if already present. + """ + def add_oban_queue(igniter, queue_name, concurrency) + when is_atom(queue_name) and is_integer(concurrency) do + app_name = IgniterHelpers.get_parent_app_name(igniter) + + try do + Igniter.update_file(igniter, "config/config.exs", fn source -> + content = Rewrite.Source.get(source, :content) + + queue_str = Atom.to_string(queue_name) + + if Regex.match?(~r/#{queue_str}:\s*\d+/, content) do + source + else + case Regex.run( + ~r/(^config\s+:#{app_name},\s+Oban.*?queues:\s*\[)(.*?)(\n\s*\])/ms, + content, + capture: :all + ) do + [full_match, before_queues, queues_content, after_queues] -> + trimmed_content = String.trim_trailing(queues_content) + has_trailing_comma = String.ends_with?(trimmed_content, ",") + + new_queue_entry = + if has_trailing_comma do + "\n #{queue_str}: #{concurrency}" + else + ",\n #{queue_str}: #{concurrency}" + end + + updated_queues = before_queues <> queues_content <> new_queue_entry <> after_queues + updated_content = String.replace(content, full_match, updated_queues, global: false) + Rewrite.Source.update(source, :content, updated_content) + + nil -> + source + end + end + end) + rescue + _ -> igniter + end + end + # Clean up broken Oban config syntax from previous failed updates # NOTE: Previously this function attempted to fix syntax issues with greedy # regexes, but they could corrupt valid commented config. The regexes have @@ -111,23 +159,21 @@ defmodule PhoenixKit.Install.ObanConfig do oban_config = """ # Configure Oban for PhoenixKit background jobs - # Required for file processing (storage system), email handling, posts, sitemap, and DB sync + # Required for file processing (storage system), posts, sitemap, and DB sync config :#{app_name}, Oban, repo: #{repo_module}, queues: [ default: 10, # General purpose queue - emails: 50, # Email processing file_processing: 20, # File variant generation (storage system) posts: 10, # Posts scheduled publishing scheduled_jobs: 1, # Scheduled jobs cron (1-day retention) sitemap: 5, # Sitemap generation - sqs_polling: 1, # SQS polling for email events (only one concurrent job) - sync: 5, # Sync data import + sync: 5, # Sync data import newsletters_delivery: 10 # Newsletters broadcast deliveries ], plugins: [ # Main pruner: 30 days for most queues - {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 30, queue: [:default, :emails, :file_processing, :posts, :sitemap, :sqs_polling, :sync]}, + {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 30, queue: [:default, :file_processing, :posts, :sitemap, :sync]}, # Dedicated pruner: 1 day only for scheduled_jobs (cron runs every minute) {Oban.Plugins.Pruner, max_age: 60 * 60 * 24, queue: [:scheduled_jobs]}, {Oban.Plugins.Cron, @@ -178,7 +224,6 @@ defmodule PhoenixKit.Install.ObanConfig do content |> ensure_posts_queue(app_name) |> ensure_sitemap_queue(app_name) - |> ensure_sqs_polling_queue(app_name) |> ensure_sync_queue(app_name) |> ensure_shop_imports_queue(app_name) |> ensure_newsletters_delivery_queue(app_name) @@ -287,51 +332,6 @@ defmodule PhoenixKit.Install.ObanConfig do end end - # Ensure sqs_polling queue exists in the queues list - defp ensure_sqs_polling_queue(content, app_name) do - # Check if sqs_polling queue already exists - if Regex.match?(~r/sqs_polling:\s*\d+/, content) do - Mix.shell().info(" ℹ️ SQS polling queue already configured") - content - else - Mix.shell().info(" ➕ Adding sqs_polling queue to Oban configuration...") - - # Find the ACTIVE queues configuration (not commented out) - case Regex.run( - ~r/(^config\s+:#{app_name},\s+Oban.*?queues:\s*\[)(.*?)(\n\s*\])/ms, - content, - capture: :all - ) do - [full_match, before_queues, queues_content, after_queues] -> - Mix.shell().info(" ✓ Found queues block, adding sqs_polling queue") - - # Remove trailing whitespace and check for comma - trimmed_content = String.trim_trailing(queues_content) - has_trailing_comma = String.ends_with?(trimmed_content, ",") - - # Add sqs_polling queue with proper formatting (no comments to avoid syntax issues) - new_queue_entry = - if has_trailing_comma do - "\n sqs_polling: 1" - else - ",\n sqs_polling: 1" - end - - updated_queues = before_queues <> queues_content <> new_queue_entry <> after_queues - - String.replace(content, full_match, updated_queues, global: false) - - nil -> - Mix.shell().error( - " ⚠️ Could not parse queues block for :#{app_name} - skipping sqs_polling queue update" - ) - - Mix.shell().error(" Please manually add: sqs_polling: 1") - content - end - end - end - # Ensure sync queue exists in the queues list defp ensure_sync_queue(content, app_name) do # Check if sync queue already exists @@ -712,7 +712,7 @@ defmodule PhoenixKit.Install.ObanConfig do Igniter.add_notice( igniter, """ - ⚙️ Oban configured for background jobs (file processing, emails, sitemap, sqs_polling) + ⚙️ Oban configured for background jobs (file processing, posts, sitemap, sync) If queues were added/updated, restart your server to apply changes. """ |> String.trim() @@ -812,18 +812,16 @@ defmodule PhoenixKit.Install.ObanConfig do repo: #{repo_module}, queues: [ default: 10, - emails: 50, file_processing: 20, posts: 10, scheduled_jobs: 1, # Scheduled jobs cron (1-day retention) sitemap: 5, - sqs_polling: 1, sync: 5, newsletters_delivery: 10 ], plugins: [ # Main pruner: 30 days for most queues - {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 30, queue: [:default, :emails, :file_processing, :posts, :sitemap, :sqs_polling, :sync]}, + {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 30, queue: [:default, :file_processing, :posts, :sitemap, :sync]}, # Dedicated pruner: 1 day only for scheduled_jobs (cron runs every minute) {Oban.Plugins.Pruner, max_age: 60 * 60 * 24, queue: [:scheduled_jobs]}, {Oban.Plugins.Cron, @@ -840,8 +838,7 @@ defmodule PhoenixKit.Install.ObanConfig do Without this configuration, the storage system cannot process uploaded files, scheduled posts will not be published automatically, sitemap generation - will not work asynchronously, SQS polling for email events will not function, - and DB Sync imports will not work. + will not work asynchronously, and DB Sync imports will not work. """ Igniter.add_notice(igniter, notice) diff --git a/lib/phoenix_kit/mailer.ex b/lib/phoenix_kit/mailer.ex index ffde1ecae..88de80341 100644 --- a/lib/phoenix_kit/mailer.ex +++ b/lib/phoenix_kit/mailer.ex @@ -24,17 +24,14 @@ defmodule PhoenixKit.Mailer do import Swoosh.Email - alias PhoenixKit.Modules.Emails - alias PhoenixKit.Modules.Emails.Interceptor - alias PhoenixKit.Modules.Emails.Template - alias PhoenixKit.Modules.Emails.Templates - alias PhoenixKit.Modules.Emails.Utils, as: EmailUtils alias PhoenixKit.Users.Auth.User - alias PhoenixKit.Utils.Date, as: UtilsDate - alias PhoenixKit.Utils.Routes require Logger + defp email_provider do + Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Email.DefaultProvider) + end + @doc """ Gets the mailer module to use for sending emails. @@ -114,7 +111,7 @@ defmodule PhoenixKit.Mailer do def send_from_template(template_name, recipient, variables \\ %{}, opts \\ []) when is_binary(template_name) do # Get the template from database - case Templates.get_active_template_by_name(template_name) do + case email_provider().get_active_template_by_name(template_name) do nil -> {:error, :template_not_found} @@ -123,7 +120,7 @@ defmodule PhoenixKit.Mailer do if template.status == "active" do # Render template with variables in the requested locale locale = Keyword.get(opts, :locale, "en") - rendered = Templates.render_template(template, variables, locale) + rendered = email_provider().render_template(template, variables, locale) # Build email email = @@ -143,10 +140,10 @@ defmodule PhoenixKit.Mailer do end # Track template usage - Templates.track_usage(template) + email_provider().track_usage(template) # Extract source_module from template metadata - source_module = Template.get_source_module(template) + source_module = email_provider().get_source_module(template) # Prepare delivery options with category and source_module from template delivery_opts = @@ -177,7 +174,7 @@ defmodule PhoenixKit.Mailer do """ def deliver_email(email, opts \\ []) do # Intercept email for tracking before sending - tracked_email = Interceptor.intercept_before_send(email, opts) + tracked_email = email_provider().intercept_before_send(email, opts) mailer = get_mailer() @@ -200,7 +197,7 @@ defmodule PhoenixKit.Mailer do end # Handle post-send tracking updates - handle_delivery_result(tracked_email, result, opts) + email_provider().handle_after_send(tracked_email, result) result end @@ -219,10 +216,14 @@ defmodule PhoenixKit.Mailer do # If using AWS SES, override with runtime settings from DB runtime_config = if config[:adapter] == Swoosh.Adapters.AmazonSES do - config - |> Keyword.put(:region, Emails.get_aws_region()) - |> Keyword.put(:access_key, Emails.get_aws_access_key()) - |> Keyword.put(:secret, Emails.get_aws_secret_key()) + if email_provider().aws_configured?() do + config + |> Keyword.put(:region, email_provider().get_aws_region()) + |> Keyword.put(:access_key, email_provider().get_aws_access_key()) + |> Keyword.put(:secret, email_provider().get_aws_secret_key()) + else + config + end else config end @@ -251,18 +252,18 @@ defmodule PhoenixKit.Mailer do # Try to get template from database, fallback to hardcoded {subject, html_body, text_body} = - case Templates.get_active_template_by_name("magic_link") do + case email_provider().get_active_template_by_name("magic_link") do nil -> - # Fallback to hardcoded templates + # Fallback to text-only { "Your secure login link", - magic_link_html_body(user, magic_link_url), + nil, magic_link_text_body(user, magic_link_url) } template -> # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) + rendered = email_provider().render_template(template, template_variables) {rendered.subject, rendered.html_body, rendered.text_body} end @@ -275,10 +276,10 @@ defmodule PhoenixKit.Mailer do |> text_body(text_body) # Track template usage if using database template - case Templates.get_active_template_by_name("magic_link") do + case email_provider().get_active_template_by_name("magic_link") do # No template to track nil -> :ok - template -> Templates.track_usage(template) + template -> email_provider().track_usage(template) end deliver_email(email, @@ -291,121 +292,14 @@ defmodule PhoenixKit.Mailer do ) end - # HTML version of the magic link email - defp magic_link_html_body(%User{} = user, magic_link_url) do - """ - - - - - - Your Secure Login Link - - - -
-
-

Secure Login Link

-
- -

Hi #{user.email},

- -

Click the button below to securely log in to your account:

- -

- Log In Securely -

- -
- ⚠️ Important: This link will expire in 15 minutes and can only be used once. -
- -

If you didn't request this login link, you can safely ignore this email.

- -

For your security, never share this link with anyone.

- - -
- - - - """ - end - # Text version of the magic link email - defp magic_link_text_body(%User{} = user, magic_link_url) do + defp magic_link_text_body(_user, magic_link_url) do """ - Secure Login Link - - Hi #{user.email}, - - Click the link below to securely log in to your account: - - #{magic_link_url} - - ⚠️ Important: This link will expire in 15 minutes and can only be used once. - - If you didn't request this login link, you can safely ignore this email. - - For your security, never share this link with anyone. + Your login link: #{magic_link_url} + This link expires in 15 minutes. """ end - # Handle delivery result for email tracking updates - defp handle_delivery_result(email, result, opts) do - # Only process if email tracking is enabled - if Emails.enabled?() do - case extract_log_uuid_from_email(email) do - nil -> - # No log UUID found, skip tracking - :ok - - log_uuid -> - case Emails.get_log(log_uuid) do - nil -> :ok - log -> update_log_after_delivery(log, result, opts) - end - end - end - rescue - # Don't fail email delivery if tracking update fails - error -> - Logger.error("Failed to update email tracking after delivery: #{inspect(error)}") - :ok - end - - # Extract log UUID from email headers - defp extract_log_uuid_from_email(email) do - case get_in(email.headers, ["X-PhoenixKit-Log-Id"]) do - nil -> nil - log_uuid when is_binary(log_uuid) -> log_uuid - end - end - - # Update email log based on delivery result - defp update_log_after_delivery(log, {:ok, response}, _opts) do - Interceptor.update_after_send(log, response) - end - - defp update_log_after_delivery(log, {:error, error}, _opts) do - Interceptor.update_after_failure(log, error) - end - - defp update_log_after_delivery(_log, _result, _opts) do - # Unknown result format, skip update - :ok - end - # Detect current email provider from configuration defp detect_provider do mailer = get_mailer() @@ -421,220 +315,18 @@ defmodule PhoenixKit.Mailer do defp detect_builtin_provider do config = PhoenixKit.Config.get(PhoenixKit.Mailer, []) adapter = Keyword.get(config, :adapter) - EmailUtils.adapter_to_provider_name(adapter, "phoenix_kit_builtin") + email_provider().adapter_to_provider_name(adapter, "phoenix_kit_builtin") end # Detect provider for parent application mailer defp detect_parent_app_provider(mailer) when is_atom(mailer) do config = PhoenixKit.Config.get_parent_app_config(mailer, []) adapter = Keyword.get(config, :adapter) - EmailUtils.adapter_to_provider_name(adapter, "parent_app_mailer") + email_provider().adapter_to_provider_name(adapter, "parent_app_mailer") end defp detect_parent_app_provider(_mailer), do: "unknown" - @doc """ - Send a test tracking email to verify email delivery and tracking functionality. - - Uses the 'test_email' template from the database if available, - falls back to hardcoded template if not found. - - This function sends a test email with test links - to verify that the email tracking system is working correctly. - - ## Parameters - - - `recipient_email` - The email address to send the test email to - - `user_uuid` - Optional user UUID to associate with the test email (default: nil) - - ## Returns - - - `{:ok, %Swoosh.Email{}}` - Email sent successfully - - `{:error, reason}` - Email failed to send - - ## Examples - - iex> PhoenixKit.Mailer.send_test_tracking_email("admin@example.com") - {:ok, %Swoosh.Email{}} - - iex> PhoenixKit.Mailer.send_test_tracking_email("admin@example.com", "019...") - {:ok, %Swoosh.Email{}} - - """ - def send_test_tracking_email(recipient_email, user_uuid \\ nil) - when is_binary(recipient_email) do - timestamp = UtilsDate.utc_now() |> DateTime.to_string() - test_link_url = Routes.url("/admin/emails") - - # Variables for template substitution - template_variables = %{ - "recipient_email" => recipient_email, - "timestamp" => timestamp, - "test_link_url" => test_link_url - } - - # Try to get template from database, fallback to hardcoded - {subject, html_body, text_body} = - case Templates.get_active_template_by_name("test_email") do - nil -> - # Fallback to hardcoded templates - { - "Test Tracking Email - #{timestamp}", - test_email_html_body(recipient_email, timestamp), - test_email_text_body(recipient_email, timestamp) - } - - template -> - # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) - {rendered.subject, rendered.html_body, rendered.text_body} - end - - email = - new() - |> to(recipient_email) - |> from({get_from_name(), get_from_email()}) - |> subject(subject) - |> html_body(html_body) - |> text_body(text_body) - - # Track template usage if using database template - case Templates.get_active_template_by_name("test_email") do - # No template to track - nil -> :ok - template -> Templates.track_usage(template) - end - - deliver_email(email, - user_uuid: user_uuid, - template_name: "test_email", - campaign_id: "test", - category: "system", - source_module: "admin", - provider: detect_provider() - ) - end - - # HTML version of the test tracking email - defp test_email_html_body(recipient_email, timestamp) do - test_link_url = Routes.url("/admin/emails") - - """ - - - - - - Test Tracking Email - - - -
-
-

📧 Test Tracking Email

-

Email Tracking System Verification

-
- -
-
- ✅ Success! This test email was sent successfully through the PhoenixKit email tracking system. -
- -

Hello,

- -

This is a test email to verify that your email tracking system is working correctly. If you received this email, it means:

- -
    -
  • ✅ Email delivery is working
  • -
  • ✅ AWS SES configuration is correct (if using SES)
  • -
  • ✅ Email tracking is enabled and logging
  • -
  • ✅ Configuration set is properly configured
  • -
- -
- 📊 Tracking Information: -
- Recipient: #{recipient_email}
- Sent at: #{timestamp}
- Campaign: test
- Template: test_email -
-
- - - -

Click any of the buttons above to test link tracking. Then check your emails in the admin panel to see the tracking data.

- -
- - -
- - - """ - end - - # Text version of the test tracking email - defp test_email_text_body(recipient_email, timestamp) do - test_link_url = Routes.url("/admin/emails") - - """ - TEST TRACKING EMAIL - EMAIL SYSTEM VERIFICATION - - Success! This test email was sent successfully through the PhoenixKit email tracking system. - - Hello, - - This is a test email to verify that your email tracking system is working correctly. If you received this email, it means: - - ✅ Email delivery is working - ✅ AWS SES configuration is correct (if using SES) - ✅ Email tracking is enabled and logging - ✅ Configuration set is properly configured - - TRACKING INFORMATION: - --------------------- - Recipient: #{recipient_email} - Sent at: #{timestamp} - Campaign: test - Template: test_email - - TEST LINKS: - ----------- - Test these tracking features by visiting: - - Test Link 1: #{test_link_url}?test=link1 - Test Link 2: #{test_link_url}?test=link2 - Test Link 3: #{test_link_url}?test=link3 - - Click any of the links above to test link tracking. Then check your emails in the admin panel to see the tracking data. - - --- - This is an automated test email from PhoenixKit Email Tracking System. - Check your admin panel at: #{test_link_url} - """ - end - # Get the from email address from configuration or use a default # Priority: Settings Database > Config file > Default defp get_from_email do diff --git a/lib/phoenix_kit/module_registry.ex b/lib/phoenix_kit/module_registry.ex index 64b9314cd..80954cffe 100644 --- a/lib/phoenix_kit/module_registry.ex +++ b/lib/phoenix_kit/module_registry.ex @@ -438,6 +438,15 @@ defmodule PhoenixKit.ModuleRegistry do "Email newsletter management with list subscriptions, broadcast campaigns, and delivery tracking.", icon: "📧", hex_url: "https://hex.pm/packages/phoenix_kit_newsletters" + }, + %{ + module: PhoenixKit.Modules.Emails, + key: "emails", + hex_package: "phoenix_kit_emails", + name: "Emails", + description: "Email tracking, analytics, templates, and AWS SES/SNS/SQS integration.", + icon: "📧", + hex_url: "https://hex.pm/packages/phoenix_kit_emails" } ] end diff --git a/lib/phoenix_kit/users/auth/user_notifier.ex b/lib/phoenix_kit/users/auth/user_notifier.ex index 23974578f..b8f997e90 100644 --- a/lib/phoenix_kit/users/auth/user_notifier.ex +++ b/lib/phoenix_kit/users/auth/user_notifier.ex @@ -27,7 +27,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do import Swoosh.Email alias PhoenixKit.Mailer - alias PhoenixKit.Modules.Emails.Templates + + defp email_provider do + Application.get_env(:phoenix_kit, :email_provider, PhoenixKit.Email.DefaultProvider) + end # Delivers the email using the appropriate mailer. # Uses the configured parent application mailer if available, @@ -105,13 +108,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do # Try to get template from database, fallback to hardcoded {subject, html_body, text_body} = - case Templates.get_active_template_by_name("register") do + case email_provider().get_active_template_by_name("register") do nil -> - # Fallback to hardcoded templates + # Fallback to text-only fallback_text = """ - - ============================== - Hi #{user.email}, You can confirm your account by visiting the URL below: @@ -119,27 +119,21 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do #{url} If you didn't create an account with us, please ignore this. - - ============================== """ - { - "Confirm your account", - confirmation_html_body(user.email, url), - fallback_text - } + {"Confirm your account", nil, fallback_text} template -> # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) + rendered = email_provider().render_template(template, template_variables) {rendered.subject, rendered.html_body, rendered.text_body} end # Track template usage if using database template - case Templates.get_active_template_by_name("register") do + case email_provider().get_active_template_by_name("register") do # No template to track nil -> :ok - template -> Templates.track_usage(template) + template -> email_provider().track_usage(template) end deliver(user.email, subject, text_body, html_body) @@ -160,13 +154,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do # Try to get template from database, fallback to hardcoded {subject, html_body, text_body} = - case Templates.get_active_template_by_name("reset_password") do + case email_provider().get_active_template_by_name("reset_password") do nil -> - # Fallback to hardcoded templates + # Fallback to text-only fallback_text = """ - - ============================== - Hi #{user.email}, You can reset your password by visiting the URL below: @@ -174,27 +165,21 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do #{url} If you didn't request this change, please ignore this. - - ============================== """ - { - "Reset your password", - reset_password_html_body(user.email, url), - fallback_text - } + {"Reset your password", nil, fallback_text} template -> # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) + rendered = email_provider().render_template(template, template_variables) {rendered.subject, rendered.html_body, rendered.text_body} end # Track template usage if using database template - case Templates.get_active_template_by_name("reset_password") do + case email_provider().get_active_template_by_name("reset_password") do # No template to track nil -> :ok - template -> Templates.track_usage(template) + template -> email_provider().track_usage(template) end deliver(user.email, subject, text_body, html_body) @@ -215,13 +200,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do # Try to get template from database, fallback to hardcoded {subject, html_body, text_body} = - case Templates.get_active_template_by_name("update_email") do + case email_provider().get_active_template_by_name("update_email") do nil -> - # Fallback to hardcoded templates + # Fallback to text-only fallback_text = """ - - ============================== - Hi #{user.email}, You can change your email by visiting the URL below: @@ -229,177 +211,26 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do #{url} If you didn't request this change, please ignore this. - - ============================== """ - { - "Confirm your email change", - update_email_html_body(user.email, url), - fallback_text - } + {"Confirm your email change", nil, fallback_text} template -> # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) + rendered = email_provider().render_template(template, template_variables) {rendered.subject, rendered.html_body, rendered.text_body} end # Track template usage if using database template - case Templates.get_active_template_by_name("update_email") do + case email_provider().get_active_template_by_name("update_email") do # No template to track nil -> :ok - template -> Templates.track_usage(template) + template -> email_provider().track_usage(template) end deliver(user.email, subject, text_body, html_body) end - # HTML template for account confirmation email - defp confirmation_html_body(email, url) do - """ - - - - - - Confirm Your Account - - - -
-
-

Welcome! Please confirm your account

-
- -

Hi #{email},

- -

Thank you for creating an account! To complete your registration, please confirm your email address by clicking the button below:

- -

- Confirm My Account -

- -
- ℹ️ Note: This confirmation link is secure and will verify your email address. -
- -

If you didn't create an account with us, you can safely ignore this email.

- - -
- - - """ - end - - # HTML template for password reset email - defp reset_password_html_body(email, url) do - """ - - - - - - Reset Your Password - - - -
-
-

Password Reset Request

-
- -

Hi #{email},

- -

We received a request to reset your password. Click the button below to create a new password:

- -

- Reset My Password -

- -
- ⚠️ Security Notice: This password reset link will expire soon for your security. -
- -

If you didn't request this password reset, you can safely ignore this email. Your password will remain unchanged.

- - -
- - - """ - end - - # HTML template for email update confirmation - defp update_email_html_body(email, url) do - """ - - - - - - Confirm Email Change - - - -
-
-

Confirm Your Email Change

-
- -

Hi #{email},

- -

We received a request to change your email address. To complete this change, please confirm your new email address by clicking the button below:

- -

- Confirm Email Change -

- -
- ✓ Verification Required: This step ensures your new email address is valid and accessible. -
- -

If you didn't request this email change, you can safely ignore this message. Your current email address will remain unchanged.

- - -
- - - """ - end - @doc """ Deliver magic link registration instructions. @@ -422,13 +253,10 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do # Try to get template from database, fallback to hardcoded {subject, html_body, text_body} = - case Templates.get_active_template_by_name("magic_link_registration") do + case email_provider().get_active_template_by_name("magic_link_registration") do nil -> - # Fallback to hardcoded templates + # Fallback to text-only fallback_text = """ - - ============================== - Hi #{email}, Welcome! To complete your registration, please click the link below: @@ -438,78 +266,23 @@ defmodule PhoenixKit.Users.Auth.UserNotifier do This link will expire in 30 minutes for your security. If you didn't request this registration, please ignore this email. - - ============================== """ - { - "Complete Your Registration", - magic_link_registration_html_body(email, url), - fallback_text - } + {"Complete Your Registration", nil, fallback_text} template -> # Use database template with variable substitution - rendered = Templates.render_template(template, template_variables) + rendered = email_provider().render_template(template, template_variables) {rendered.subject, rendered.html_body, rendered.text_body} end # Track template usage if using database template - case Templates.get_active_template_by_name("magic_link_registration") do + case email_provider().get_active_template_by_name("magic_link_registration") do # No template to track nil -> :ok - template -> Templates.track_usage(template) + template -> email_provider().track_usage(template) end deliver(email, subject, text_body, html_body) end - - # HTML template for magic link registration email - defp magic_link_registration_html_body(email, url) do - """ - - - - - - Complete Your Registration - - - -
-
-

Welcome! Complete Your Registration

-
- -

Hi #{email},

- -

Thank you for starting your registration! Click the button below to complete your account setup:

- -

- Complete Registration -

- -
- ℹ️ Security Note: This registration link will expire in 30 minutes and can only be used once. -
- -

If you didn't request this registration, you can safely ignore this email.

- - -
- - - """ - end end From af6af2ea7db877e3e966ba1651ba7ddcd86693fc Mon Sep 17 00:00:00 2001 From: timujeen Date: Thu, 19 Mar 2026 22:12:54 +0000 Subject: [PATCH 03/10] Add AdminEditHelper for universal admin edit links in public views - New PhoenixKitWeb.AdminEditHelper module (supports both Plug.Conn and LiveView.Socket) - Fix Shop catalog: admin edit URL now only assigned for admin users (was assigned to all visitors) - Add admin edit links to Publishing controller (blog listing, post show, date URL) - Add conditional Edit button in Publishing templates (index + show) --- lib/modules/publishing/web/controller.ex | 14 ++++++++ .../publishing/web/templates/index.html.heex | 19 +++++++--- .../publishing/web/templates/show.html.heex | 7 ++++ lib/modules/shop/web/catalog_category.ex | 2 +- lib/modules/shop/web/catalog_product.ex | 6 ++-- .../helpers/admin_edit_helper.ex | 36 +++++++++++++++++++ 6 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 lib/phoenix_kit_web/helpers/admin_edit_helper.ex diff --git a/lib/modules/publishing/web/controller.ex b/lib/modules/publishing/web/controller.ex index 11d14cadc..c27172e54 100644 --- a/lib/modules/publishing/web/controller.ex +++ b/lib/modules/publishing/web/controller.ex @@ -33,6 +33,8 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do alias PhoenixKit.Modules.Publishing.Web.Controller.Routing alias PhoenixKit.Modules.Publishing.Web.HTML, as: PublishingHTML alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes + alias PhoenixKitWeb.AdminEditHelper # ============================================================================ # Main Entry Points @@ -183,6 +185,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do locale: assigns.current_language, type: "website" }) + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/publishing/#{group_slug}"), + "Edit Blog" + ) |> render(:index) {:redirect, url} -> @@ -213,6 +219,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:breadcrumbs, assigns.breadcrumbs) |> assign(:version_dropdown, assigns.version_dropdown) |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language)) + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/publishing/#{group_slug}/#{assigns.post.uuid}/edit"), + "Edit Post" + ) |> render(:show) {:redirect, url} -> @@ -271,6 +281,10 @@ defmodule PhoenixKit.Modules.Publishing.Web.Controller do |> assign(:breadcrumbs, assigns.breadcrumbs) |> assign(:version_dropdown, assigns.version_dropdown) |> assign(:og, build_og_data(conn, assigns.post, canonical_url, assigns.current_language)) + |> AdminEditHelper.assign_admin_edit( + Routes.path("/admin/publishing/#{group_slug}/#{assigns.post.uuid}/edit"), + "Edit Post" + ) |> render(:show) {:redirect, url} -> diff --git a/lib/modules/publishing/web/templates/index.html.heex b/lib/modules/publishing/web/templates/index.html.heex index fa8a16962..132b9f86f 100644 --- a/lib/modules/publishing/web/templates/index.html.heex +++ b/lib/modules/publishing/web/templates/index.html.heex @@ -20,10 +20,21 @@ <%!-- Group Header --%>
-

{@group["name"]}

-

- {ngettext("1 post", "%{count} posts", @total_count)} -

+
+
+

{@group["name"]}

+

+ {ngettext("1 post", "%{count} posts", @total_count)} +

+
+ <%!-- Admin Edit Button --%> + <%= if assigns[:admin_edit_url] do %> + + <.icon name="hero-pencil-square" class="w-4 h-4" /> + {@admin_edit_label || "Edit"} + + <% end %> +
<%!-- Language Switcher --%> <%= if length(@translations) > 1 do %>
diff --git a/lib/modules/publishing/web/templates/show.html.heex b/lib/modules/publishing/web/templates/show.html.heex index 29d53eead..1f4e738eb 100644 --- a/lib/modules/publishing/web/templates/show.html.heex +++ b/lib/modules/publishing/web/templates/show.html.heex @@ -48,6 +48,13 @@ size={:sm} /> <% end %> + <%!-- Admin Edit Button --%> + <%= if assigns[:admin_edit_url] do %> + + <.icon name="hero-pencil-square" class="w-4 h-4" /> + {@admin_edit_label || "Edit"} + + <% end %> <%!-- Version History Dropdown --%> <%= if @version_dropdown do %>
@@ -308,7 +328,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do > <%!-- Backdrop --%>
@@ -356,7 +376,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <%= for category <- @categories do %>
@@ -374,7 +394,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do <% end %>
-

+

{category.description}

@@ -411,22 +431,37 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
<%!-- Policy Links --%> -
- - {gettext("Privacy Policy")} - - - - {gettext("Cookie Policy")} - +
+ <%= if @legal_links == [] do %> + + {gettext("Privacy Policy")} + + + + {gettext("Cookie Policy")} + + <% else %> + <%= for {link, index} <- Enum.with_index(@legal_links) do %> + <%= if index > 0 do %> + + <% end %> + + {link.title} + + <% end %> + <% end %>
<%!-- Action Buttons --%> diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index 5f77299a7..c3f64a78b 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -700,6 +700,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do policy_version={config.policy_version} cookie_policy_url={config.cookie_policy_url} privacy_policy_url={config.privacy_policy_url} + legal_links={config.legal_links} google_consent_mode={config.google_consent_mode} /> <% end %> diff --git a/lib/phoenix_kit_web/integration.ex b/lib/phoenix_kit_web/integration.ex index 8d0abd304..94d6db032 100644 --- a/lib/phoenix_kit_web/integration.ex +++ b/lib/phoenix_kit_web/integration.ex @@ -101,7 +101,6 @@ defmodule PhoenixKitWeb.Integration do alias PhoenixKitWeb alias PhoenixKitWeb.Routes.BlogRoutes alias PhoenixKitWeb.Routes.CustomerServiceRoutes - alias PhoenixKitWeb.Routes.EmailsRoutes alias PhoenixKitWeb.Routes.PublishingRoutes alias PhoenixKitWeb.Routes.ReferralsRoutes alias PhoenixKitWeb.Routes.ShopRoutes @@ -426,9 +425,6 @@ defmodule PhoenixKitWeb.Integration do # so plugin LiveViews don't need to wrap with LayoutWrapper themselves plugin_admin_routes = compile_plugin_admin_routes(__CALLER__.module) - # Get external route module AST outside quote to avoid require/alias inside quote - emails_admin = safe_route_call(EmailsRoutes, :admin_routes, []) - {tickets_admin, publishing_admin, referrals_admin} = if suffix == :_locale do { @@ -751,7 +747,6 @@ defmodule PhoenixKitWeb.Integration do as: :ai_prompt_edit # Routes from external route modules - unquote(emails_admin) unquote(tickets_admin) unquote(publishing_admin) unquote(referrals_admin) @@ -1342,7 +1337,6 @@ defmodule PhoenixKitWeb.Integration do # Call route generators BEFORE quote block (aliases work in this context) # Uses safe_route_call/3 so modules can be safely extracted to separate packages - emails_routes = safe_route_call(EmailsRoutes, :generate, [url_prefix]) publishing_routes = safe_route_call(PublishingRoutes, :generate, [url_prefix]) customer_service_routes = safe_route_call(CustomerServiceRoutes, :generate, [url_prefix]) blog_routes = safe_route_call(BlogRoutes, :generate, [url_prefix]) @@ -1365,7 +1359,6 @@ defmodule PhoenixKitWeb.Integration do unquote_splicing(module_public_routes) # Generate module routes from separate files (improves compilation time) - unquote(emails_routes) unquote(publishing_routes) unquote(customer_service_routes) diff --git a/lib/phoenix_kit_web/live/dashboard.html.heex b/lib/phoenix_kit_web/live/dashboard.html.heex index efc4d0c66..19c758fe5 100644 --- a/lib/phoenix_kit_web/live/dashboard.html.heex +++ b/lib/phoenix_kit_web/live/dashboard.html.heex @@ -93,7 +93,7 @@
- <%= if PhoenixKit.Modules.Emails.enabled?() do %> + <%= if Code.ensure_loaded?(PhoenixKit.Modules.Emails) and apply(PhoenixKit.Modules.Emails, :enabled?, []) do %> <.link navigate={PhoenixKit.Utils.Routes.path("/admin/emails")} class="card bg-purple-500 text-white hover:shadow-lg transition-all" diff --git a/lib/phoenix_kit_web/routes/emails.ex b/lib/phoenix_kit_web/routes/emails.ex deleted file mode 100644 index 99f4621ad..000000000 --- a/lib/phoenix_kit_web/routes/emails.ex +++ /dev/null @@ -1,77 +0,0 @@ -defmodule PhoenixKitWeb.Routes.EmailsRoutes do - @moduledoc """ - Email module routes. - - Provides route definitions for email webhooks, exports, and admin interfaces. - Separated to improve compilation time. - """ - - @doc """ - Returns quoted code for email non-LiveView routes (webhooks, exports). - """ - def generate(url_prefix) do - quote do - # Email webhook endpoint (public - no authentication required) - scope unquote(url_prefix) do - pipe_through [:browser] - - post "/webhooks/email", PhoenixKit.Modules.Emails.Web.WebhookController, :handle - end - - # Email export routes (require admin or owner role) - scope unquote(url_prefix) do - pipe_through [:browser, :phoenix_kit_auto_setup, :phoenix_kit_admin_only] - - get "/admin/emails/export", PhoenixKit.Modules.Emails.Web.ExportController, :export_logs - - get "/admin/emails/metrics/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_metrics - - get "/admin/emails/blocklist/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_blocklist - - get "/admin/emails/:id/export", - PhoenixKit.Modules.Emails.Web.ExportController, - :export_email_details - end - end - end - - @doc """ - Returns quoted admin LiveView route declarations for inclusion in the shared admin live_session. - """ - def admin_routes do - quote do - live "/admin/settings/emails", PhoenixKit.Modules.Emails.Web.Settings, :index, - as: :emails_settings - - live "/admin/emails/dashboard", PhoenixKit.Modules.Emails.Web.Metrics, :index, - as: :emails_metrics - - live "/admin/emails", PhoenixKit.Modules.Emails.Web.Emails, :index, as: :emails_index - - live "/admin/emails/email/:id", PhoenixKit.Modules.Emails.Web.Details, :show, - as: :emails_details - - live "/admin/emails/queue", PhoenixKit.Modules.Emails.Web.Queue, :index, as: :emails_queue - - live "/admin/emails/blocklist", PhoenixKit.Modules.Emails.Web.Blocklist, :index, - as: :emails_blocklist - - live "/admin/emails/templates", PhoenixKit.Modules.Emails.Web.Templates, :index, - as: :emails_templates - - live "/admin/emails/templates/new", - PhoenixKit.Modules.Emails.Web.TemplateEditor, - :new, - as: :emails_template_new - - live "/admin/emails/templates/:id/edit", - PhoenixKit.Modules.Emails.Web.TemplateEditor, - :edit, - as: :emails_template_edit - end - end -end diff --git a/test/phoenix_kit/module_test.exs b/test/phoenix_kit/module_test.exs index 7c17a5ea4..6f58f52b7 100644 --- a/test/phoenix_kit/module_test.exs +++ b/test/phoenix_kit/module_test.exs @@ -9,7 +9,6 @@ defmodule PhoenixKit.ModuleTest do PhoenixKit.Modules.Comments, PhoenixKit.Modules.Connections, PhoenixKit.Modules.DB, - PhoenixKit.Modules.Emails, PhoenixKit.Modules.Entities, PhoenixKit.Modules.Languages, PhoenixKit.Modules.Legal, From 0451081317aec5df1a456db6ce4e021da1d4d347 Mon Sep 17 00:00:00 2001 From: timujeen Date: Fri, 20 Mar 2026 21:10:50 +0000 Subject: [PATCH 05/10] Fix cookie consent: dynamic legal links, theme-aware backdrop, daisyUI toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded cookie/privacy URLs with Routes.path() to fix double-slash bug - Add dynamic legal_links from published pages + single /legal index link - Use bg-base-100/70 backdrop instead of bg-black for light/dark theme compatibility - Improve glass opacity (0.95→0.98), card bg (50→80%), text contrast - Replace custom toggle with standard daisyUI toggle toggle-primary --- lib/modules/legal/legal.ex | 13 +- .../components/core/cookie_consent.ex | 116 ++++-------------- .../components/layout_wrapper.ex | 1 + 3 files changed, 30 insertions(+), 100 deletions(-) diff --git a/lib/modules/legal/legal.ex b/lib/modules/legal/legal.ex index d914e3d4c..d90d65ee6 100644 --- a/lib/modules/legal/legal.ex +++ b/lib/modules/legal/legal.ex @@ -38,6 +38,7 @@ defmodule PhoenixKit.Modules.Legal do alias PhoenixKit.Modules.Legal.PageType alias PhoenixKit.Modules.Legal.TemplateGenerator alias PhoenixKit.Settings + alias PhoenixKit.Utils.Routes @enabled_key "legal_enabled" @module_name "legal" @@ -557,19 +558,18 @@ defmodule PhoenixKit.Modules.Legal do """ @spec get_consent_widget_config() :: map() def get_consent_widget_config do - prefix = PhoenixKit.Config.get_url_prefix() legal_links = get_published_legal_links() cookie_policy_url = case Enum.find(legal_links, &String.ends_with?(&1.url, "/cookie-policy")) do %{url: url} -> url - nil -> "#{prefix}/legal/cookie-policy" + nil -> Routes.path("/legal/cookie-policy") end privacy_policy_url = case Enum.find(legal_links, &String.ends_with?(&1.url, "/privacy-policy")) do %{url: url} -> url - nil -> "#{prefix}/legal/privacy-policy" + nil -> Routes.path("/legal/privacy-policy") end %{ @@ -583,7 +583,8 @@ defmodule PhoenixKit.Modules.Legal do frameworks: get_selected_frameworks(), cookie_policy_url: cookie_policy_url, privacy_policy_url: privacy_policy_url, - legal_links: legal_links + legal_links: legal_links, + legal_index_url: Routes.path("/legal") } end @@ -595,11 +596,9 @@ defmodule PhoenixKit.Modules.Legal do """ @spec get_published_legal_links() :: list(%{title: String.t(), url: String.t()}) def get_published_legal_links do - prefix = PhoenixKit.Config.get_url_prefix() - list_generated_pages() |> Enum.filter(&(&1.status == "published")) - |> Enum.map(&%{title: &1.title, url: "#{prefix}/legal/#{&1.slug}"}) + |> Enum.map(&%{title: &1.title, url: Routes.path("/legal/#{&1.slug}")}) end @doc """ diff --git a/lib/phoenix_kit_web/components/core/cookie_consent.ex b/lib/phoenix_kit_web/components/core/cookie_consent.ex index e7061dbd3..2ae48bbf0 100644 --- a/lib/phoenix_kit_web/components/core/cookie_consent.ex +++ b/lib/phoenix_kit_web/components/core/cookie_consent.ex @@ -80,6 +80,8 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do default: [], doc: "Dynamic list of %{title, url} for published legal pages" + attr :legal_index_url, :string, default: "/legal", doc: "URL to legal pages index" + attr :google_consent_mode, :boolean, default: false, doc: "Enable Google Consent Mode v2" attr :class, :string, default: "" @@ -195,25 +197,6 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do transform: translateY(-2px); box-shadow: 0 4px 12px oklch(var(--bc) / 0.1); } - - .pk-toggle-track { - background: var(--pk-border); - transition: background-color 0.2s ease; - } - - .pk-toggle-track.active { - background: var(--pk-primary); - } - - .pk-toggle-thumb { - background: var(--pk-bg); - box-shadow: 0 1px 3px oklch(var(--bc) / 0.2); - transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); - } - - input:checked + .pk-toggle-track .pk-toggle-thumb { - transform: translateX(20px); - } <%!-- Floating Icon (only for opt-in frameworks) --%> @@ -264,28 +247,12 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do "We use cookies to enhance your browsing experience and analyze our traffic." )} {" "} - <%= if @legal_links == [] do %> - - {gettext("Cookie Policy")} - - <% else %> - <%= for {link, index} <- Enum.with_index(@legal_links) do %> - <%= if index > 0 do %> - - <% end %> - - {link.title} - - <% end %> - <% end %> + + {gettext("Legal")} +

@@ -328,7 +295,7 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do > <%!-- Backdrop --%>
@@ -401,27 +368,14 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
<%!-- Custom Toggle --%> - + <% end %> @@ -432,36 +386,12 @@ defmodule PhoenixKitWeb.Components.Core.CookieConsent do
<%!-- Policy Links --%>
- <%= if @legal_links == [] do %> - - {gettext("Privacy Policy")} - - - - {gettext("Cookie Policy")} - - <% else %> - <%= for {link, index} <- Enum.with_index(@legal_links) do %> - <%= if index > 0 do %> - - <% end %> - - {link.title} - - <% end %> - <% end %> + + {gettext("Legal")} +
<%!-- Action Buttons --%> diff --git a/lib/phoenix_kit_web/components/layout_wrapper.ex b/lib/phoenix_kit_web/components/layout_wrapper.ex index c3f64a78b..e557c0c41 100644 --- a/lib/phoenix_kit_web/components/layout_wrapper.ex +++ b/lib/phoenix_kit_web/components/layout_wrapper.ex @@ -701,6 +701,7 @@ defmodule PhoenixKitWeb.Components.LayoutWrapper do cookie_policy_url={config.cookie_policy_url} privacy_policy_url={config.privacy_policy_url} legal_links={config.legal_links} + legal_index_url={config.legal_index_url} google_consent_mode={config.google_consent_mode} /> <% end %> From 1e03f22e1ea83b57716959f8df3fae3e28834ed5 Mon Sep 17 00:00:00 2001 From: timujeen Date: Fri, 20 Mar 2026 21:44:26 +0000 Subject: [PATCH 06/10] =?UTF-8?q?Remove=20hardcoded=20Emails=20block=20fro?= =?UTF-8?q?m=20Modules=20page=20=E2=80=94=20now=20rendered=20as=20external?= =?UTF-8?q?=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/phoenix_kit_web/live/modules.html.heex | 76 ---------------------- 1 file changed, 76 deletions(-) diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index 003a3926c..b835fc524 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -135,82 +135,6 @@ <% end %> - <%!-- Email Module --%> - <%= if "emails" in @accessible_modules do %> - <% cfg = @module_configs["emails"] || %{} %> - - <:status_badges> - - {if cfg[:enabled], do: "Enabled", else: "Disabled"} - - <%= if cfg[:enabled] do %> - - {if cfg[:save_body], do: "Body Saved", else: "Headers Only"} - - <% end %> - - - <:action_buttons> -
- <%= if cfg[:enabled] do %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/emails")} - class="btn btn-primary btn-sm" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure - -
- <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/emails")} - class="btn btn-outline btn-sm flex-1" - > - <.icon name="hero-envelope" class="w-4 h-4 mr-1" /> Emails - - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/emails/templates")} - class="btn btn-outline btn-sm flex-1" - > - <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> Templates - -
- <% else %> - <.link - navigate={PhoenixKit.Utils.Routes.path("/admin/settings/emails")} - class="btn btn-outline btn-sm" - > - <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure - - <% end %> -
- - - <:stats> -
-
- SES Events: - - {if cfg[:ses_events], do: "On", else: "Off"} - -
-
- Retention: - {cfg[:retention_days]} days -
-
- -
- <% end %> - <%!-- Languages Module --%> <%= if "languages" in @accessible_modules do %> <% cfg = @module_configs["languages"] || %{} %> From e3b8cf0ca71f7d3589039d565bb4683d2f10032e Mon Sep 17 00:00:00 2001 From: timujeen Date: Sat, 21 Mar 2026 12:05:11 +0000 Subject: [PATCH 07/10] Enrich external module cards with config stats, settings link, and module_card component --- lib/phoenix_kit_web/live/modules.ex | 59 +++++--- lib/phoenix_kit_web/live/modules.html.heex | 158 +++++++++++++-------- 2 files changed, 139 insertions(+), 78 deletions(-) diff --git a/lib/phoenix_kit_web/live/modules.ex b/lib/phoenix_kit_web/live/modules.ex index 42890fd64..c1322b750 100644 --- a/lib/phoenix_kit_web/live/modules.ex +++ b/lib/phoenix_kit_web/live/modules.ex @@ -446,27 +446,50 @@ defmodule PhoenixKitWeb.Live.Modules do |> Enum.filter(fn mod -> Code.ensure_loaded?(mod) and function_exported?(mod, :module_key, 0) end) - |> Enum.map(fn mod -> - key = mod.module_key() - config = module_configs[key] || %{} - perm = if function_exported?(mod, :permission_metadata, 0), do: mod.permission_metadata() - - %{ - module: mod, - key: key, - name: mod.module_name(), - icon: (perm && perm[:icon]) || "hero-puzzle-piece", - description: (perm && perm[:description]) || "External module", - enabled: config[:enabled] || false, - version: if(function_exported?(mod, :version, 0), do: mod.version(), else: "0.0.0"), - required_modules: - if(function_exported?(mod, :required_modules, 0), do: mod.required_modules(), else: []), - admin_links: extract_admin_links(mod) - } - end) + |> Enum.map(&build_external_module_data(&1, module_configs)) |> Enum.sort_by(& &1.name) end + defp build_external_module_data(mod, module_configs) do + key = mod.module_key() + config = module_configs[key] || %{} + perm = if function_exported?(mod, :permission_metadata, 0), do: mod.permission_metadata() + + %{ + module: mod, + key: key, + name: mod.module_name(), + icon: (perm && perm[:icon]) || "hero-puzzle-piece", + description: (perm && perm[:description]) || "External module", + enabled: config[:enabled] || false, + config: safe_get_config(mod), + version: if(function_exported?(mod, :version, 0), do: mod.version(), else: "0.0.0"), + required_modules: + if(function_exported?(mod, :required_modules, 0), do: mod.required_modules(), else: []), + admin_links: extract_admin_links(mod), + settings_path: extract_settings_path(mod) + } + end + + defp safe_get_config(mod) do + if function_exported?(mod, :get_config, 0), do: mod.get_config(), else: %{} + rescue + _ -> %{} + end + + defp extract_settings_path(mod) do + if Code.ensure_loaded?(mod) and function_exported?(mod, :settings_tabs, 0) do + case mod.settings_tabs() do + [first | _] -> "/admin/settings/" <> first.path + _ -> nil + end + else + nil + end + rescue + _ -> nil + end + defp extract_admin_links(mod) do if Code.ensure_loaded?(mod) and function_exported?(mod, :admin_tabs, 0) do mod.admin_tabs() diff --git a/lib/phoenix_kit_web/live/modules.html.heex b/lib/phoenix_kit_web/live/modules.html.heex index b835fc524..6c55667c2 100644 --- a/lib/phoenix_kit_web/live/modules.html.heex +++ b/lib/phoenix_kit_web/live/modules.html.heex @@ -1255,73 +1255,111 @@ <% end %> <%!-- External Modules (auto-discovered) --%> <%= for ext <- @external_modules, ext.key in @accessible_modules do %> -
-
-
-
- <%= if String.starts_with?(ext.icon, "hero-") do %> - <.icon name={ext.icon} class="w-8 h-8" /> - <% else %> - {ext.icon} - <% end %> -
-
-

{ext.name}

-

{ext.description}

-
-
- -
-
- -
- -
-
- - {if ext.enabled, do: "Enabled", else: "Disabled"} + + <:status_badges> + + {if ext.enabled, do: "Enabled", else: "Disabled"} + + v{ext.version} + External + <%= for req_mod <- ext.required_modules do %> + <%= unless mcfg(@module_configs, req_mod, :enabled, false) do %> + + Requires {String.capitalize(req_mod)} - v{ext.version} - External - <%= for req_mod <- ext.required_modules do %> - <%= unless mcfg(@module_configs, req_mod, :enabled, false) do %> - - Requires {String.capitalize(req_mod)} - - <% end %> - <% end %> -
- - {if ext.enabled, do: "Module is active", else: "Enable to activate"} - -
+ <% end %> + <% end %> + <%!-- Module-specific badges from config --%> + <%= if ext.enabled do %> + <%= if Map.has_key?(ext.config, :save_body) do %> + + {if ext.config[:save_body], do: "Body Saved", else: "Headers Only"} + + <% end %> + <% end %> + - <%= if ext.enabled and ext.admin_links != [] do %> -
- <%= for link <- ext.admin_links do %> + <:action_buttons> +
+ <%= if ext.enabled do %> + <%= if ext.settings_path do %> <.link - navigate={PhoenixKit.Utils.Routes.path(link.path)} - class="btn btn-outline btn-sm flex-1" + navigate={PhoenixKit.Utils.Routes.path(ext.settings_path)} + class="btn btn-primary btn-sm" > - <%= if link.icon do %> - <.icon name={link.icon} class="w-4 h-4 mr-1" /> + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure + + <% end %> + <%= if ext.admin_links != [] do %> +
+ <%= for link <- ext.admin_links do %> + <.link + navigate={PhoenixKit.Utils.Routes.path(link.path)} + class="btn btn-outline btn-sm flex-1" + > + <%= if link.icon do %> + <.icon name={link.icon} class="w-4 h-4 mr-1" /> + <% end %> + {link.label} + <% end %> - {link.label} +
+ <% end %> + <% else %> + <%= if ext.settings_path do %> + <.link + navigate={PhoenixKit.Utils.Routes.path(ext.settings_path)} + class="btn btn-outline btn-sm" + > + <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-1" /> Configure <% end %> -
- <% end %> -
-
+ <% end %> +
+ + + <:stats> + <%!-- Render stats from config if available --%> +
+ <%= if Map.has_key?(ext.config, :ses_events) do %> +
+ SES Events: + + {if ext.config[:ses_events], do: "On", else: "Off"} + +
+ <% end %> + <%= if Map.has_key?(ext.config, :retention_days) do %> +
+ Retention: + {ext.config[:retention_days]} days +
+ <% end %> + <%= if Map.has_key?(ext.config, :sampling_rate) do %> +
+ Sampling: + {ext.config[:sampling_rate]}% +
+ <% end %> + <%= if Map.has_key?(ext.config, :compress_after_days) do %> +
+ Compress: + {ext.config[:compress_after_days]} days +
+ <% end %> +
+ + <% end %>
From a1f3996adad5c038d31c43fd76ffc453df22944c Mon Sep 17 00:00:00 2001 From: timujeen Date: Mon, 23 Mar 2026 21:41:50 +0000 Subject: [PATCH 08/10] Fix module_card to render hero-* icons properly --- lib/phoenix_kit_web/components/core/module_card.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_kit_web/components/core/module_card.ex b/lib/phoenix_kit_web/components/core/module_card.ex index bc4508548..2810ea4db 100644 --- a/lib/phoenix_kit_web/components/core/module_card.ex +++ b/lib/phoenix_kit_web/components/core/module_card.ex @@ -9,6 +9,8 @@ defmodule PhoenixKitWeb.Components.Core.ModuleCard do use Phoenix.Component + import PhoenixKitWeb.Components.Core.Icon, only: [icon: 1] + @doc """ Renders a module card with header, toggle, status, actions, and optional stats. @@ -77,7 +79,13 @@ defmodule PhoenixKitWeb.Components.Core.ModuleCard do
<%!-- Header: Icon, Title, Description, Toggle --%>
-
{@icon}
+
+ <%= if String.starts_with?(@icon, "hero-") do %> + <.icon name={@icon} class="w-8 h-8" /> + <% else %> + {@icon} + <% end %> +

{@title}

From 545b0e73b6769801d2e18ea50b5d29e47f77470e Mon Sep 17 00:00:00 2001 From: timujeen Date: Mon, 23 Mar 2026 23:04:13 +0000 Subject: [PATCH 09/10] Fix external_plugin_view? to recognize PhoenixKit.Modules.*.Web as external packages --- lib/phoenix_kit_web/users/auth.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/phoenix_kit_web/users/auth.ex b/lib/phoenix_kit_web/users/auth.ex index 4cb53e894..3989902e4 100644 --- a/lib/phoenix_kit_web/users/auth.ex +++ b/lib/phoenix_kit_web/users/auth.ex @@ -947,6 +947,8 @@ defmodule PhoenixKitWeb.Users.Auth do defp external_plugin_view?(view) do case Module.split(view) do ["PhoenixKitWeb" | _] -> false + # Extracted packages keep PhoenixKit.Modules.*.Web namespace — treat as external + ["PhoenixKit", "Modules", _, "Web" | _] -> true ["PhoenixKit" | _] -> false _ -> true end From 4c38e914edab358e6511b72b233794b25cfb9000 Mon Sep 17 00:00:00 2001 From: timujeen Date: Mon, 23 Mar 2026 23:24:19 +0000 Subject: [PATCH 10/10] Fix extract_admin_links: skip parent tabs, deduplicate paths --- lib/phoenix_kit_web/live/modules.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/phoenix_kit_web/live/modules.ex b/lib/phoenix_kit_web/live/modules.ex index c1322b750..6449fdadc 100644 --- a/lib/phoenix_kit_web/live/modules.ex +++ b/lib/phoenix_kit_web/live/modules.ex @@ -493,7 +493,10 @@ defmodule PhoenixKitWeb.Live.Modules do defp extract_admin_links(mod) do if Code.ensure_loaded?(mod) and function_exported?(mod, :admin_tabs, 0) do mod.admin_tabs() - |> Enum.filter(fn tab -> tab.live_view != nil and tab.visible != false end) + |> Enum.filter(fn tab -> + tab.live_view != nil and tab.visible != false and tab.parent != nil + end) + |> Enum.uniq_by(fn tab -> tab.path end) |> Enum.take(3) |> Enum.map(fn tab -> %{label: tab.label, path: "/admin/" <> tab.path, icon: tab.icon} end) else