diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index a865173..8519318 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -4629,6 +4629,37 @@ color: var(--admin-text-faintest); } +.nav-editor-select { + flex: 1; + min-width: 0; + font-size: 0.875rem; + padding: 0.375rem 0.5rem; +} + +.nav-editor-item { + flex-wrap: wrap; +} + +.nav-editor-external { + flex-basis: 100%; + padding-top: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.nav-editor-error { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: var(--t-text-error); +} + +.admin-input-error { + border-color: var(--t-border-error); +} + /* ── Page editor ── */ .admin-editor-badges { diff --git a/lib/berrypod_web/components/core_components.ex b/lib/berrypod_web/components/core_components.ex index 69513b8..b8ae21b 100644 --- a/lib/berrypod_web/components/core_components.ex +++ b/lib/berrypod_web/components/core_components.ex @@ -86,7 +86,8 @@ defmodule BerrypodWeb.CoreComponents do <.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" /> <.icon :if={@status == :saved} name="hero-check" class="size-4" /> diff --git a/lib/berrypod_web/controllers/navigation_controller.ex b/lib/berrypod_web/controllers/navigation_controller.ex new file mode 100644 index 0000000..a2ded46 --- /dev/null +++ b/lib/berrypod_web/controllers/navigation_controller.ex @@ -0,0 +1,81 @@ +defmodule BerrypodWeb.NavigationController do + @moduledoc """ + No-JS fallback for navigation form submission. + + With JS enabled, the LiveView handles everything. Without JS, + the form POSTs here and we redirect back to the LiveView page. + """ + use BerrypodWeb, :controller + + alias Berrypod.Settings + + def save(conn, %{"header_nav" => header_json, "footer_nav" => footer_json}) do + with {:ok, header_items} <- Jason.decode(header_json), + {:ok, footer_items} <- Jason.decode(footer_json) do + all_items = header_items ++ footer_items + errors = validate_nav_items(all_items) + + if errors == [] do + Settings.put_setting("header_nav", header_items, "json") + Settings.put_setting("footer_nav", footer_items, "json") + + conn + |> put_flash(:info, "Navigation saved") + |> redirect(to: ~p"/admin/navigation") + else + conn + |> put_flash(:error, Enum.join(errors, ". ")) + |> redirect(to: ~p"/admin/navigation") + end + else + {:error, _} -> + conn + |> put_flash(:error, "Invalid navigation data") + |> redirect(to: ~p"/admin/navigation") + end + end + + defp validate_nav_items(items) do + items + |> Enum.flat_map(fn item -> + cond do + item["label"] == "" || item["label"] == nil -> + ["All links need a label"] + + item["href"] == "" || item["href"] == nil -> + ["\"#{item["label"]}\" needs a destination"] + + item["external"] == true || is_external_url?(item["href"]) -> + if valid_url?(item["href"]) do + [] + else + ["\"#{item["label"]}\" has an invalid URL"] + end + + true -> + [] + end + end) + |> Enum.uniq() + end + + defp valid_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp valid_url?(_), do: false + + defp is_external_url?(nil), do: false + defp is_external_url?(""), do: false + + defp is_external_url?(href) do + String.starts_with?(href, "http://") || String.starts_with?(href, "https://") + end +end diff --git a/lib/berrypod_web/controllers/settings_controller.ex b/lib/berrypod_web/controllers/settings_controller.ex new file mode 100644 index 0000000..22dd884 --- /dev/null +++ b/lib/berrypod_web/controllers/settings_controller.ex @@ -0,0 +1,42 @@ +defmodule BerrypodWeb.SettingsController do + @moduledoc """ + No-JS fallback for settings form submissions. + + With JS enabled, the LiveView handles everything. Without JS, + the forms POST here and we redirect back to the LiveView page. + """ + use BerrypodWeb, :controller + + alias Berrypod.Settings + alias Berrypod.Stripe.Setup, as: StripeSetup + + def update_from_address(conn, %{"from_address" => address}) do + address = String.trim(address) + + if address != "" do + Settings.put_setting("email_from_address", address) + + conn + |> put_flash(:info, "From address saved") + |> redirect(to: ~p"/admin/settings") + else + conn + |> put_flash(:error, "From address can't be blank") + |> redirect(to: ~p"/admin/settings") + end + end + + def update_signing_secret(conn, %{"webhook" => %{"signing_secret" => secret}}) do + if secret == "" do + conn + |> put_flash(:error, "Please enter a signing secret") + |> redirect(to: ~p"/admin/settings") + else + StripeSetup.save_signing_secret(secret) + + conn + |> put_flash(:info, "Signing secret saved") + |> redirect(to: ~p"/admin/settings") + end + end +end diff --git a/lib/berrypod_web/live/admin/email_settings.ex b/lib/berrypod_web/live/admin/email_settings.ex index 229bdda..bb45fd2 100644 --- a/lib/berrypod_web/live/admin/email_settings.ex +++ b/lib/berrypod_web/live/admin/email_settings.ex @@ -68,7 +68,7 @@ defmodule BerrypodWeb.Admin.EmailSettings do @impl true def handle_event("form_change", %{"email" => %{"adapter" => key}}, socket) do if key == socket.assigns.adapter_key do - {:noreply, socket} + {:noreply, assign(socket, :save_status, :idle)} else {:noreply, socket @@ -76,7 +76,8 @@ defmodule BerrypodWeb.Admin.EmailSettings do |> assign(:selected_adapter, Adapters.get(key)) |> assign(:field_errors, %{}) |> assign(:test_result, nil) - |> assign(:test_error, nil)} + |> assign(:test_error, nil) + |> assign(:save_status, :idle)} end end @@ -94,8 +95,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do socket.assigns.current_scope.user.email ) do {:ok, _adapter_info} -> - Process.send_after(self(), :clear_save_status, 3000) - {:noreply, socket |> assign(:adapter_key, adapter_key) @@ -145,10 +144,6 @@ defmodule BerrypodWeb.Admin.EmailSettings do # Swoosh test adapter sends {:email, ...} messages — ignore them def handle_info({:email, _}, socket), do: {:noreply, socket} - def handle_info(:clear_save_status, socket) do - {:noreply, assign(socket, :save_status, :idle)} - end - @impl true def render(assigns) do ~H""" diff --git a/lib/berrypod_web/live/admin/navigation.ex b/lib/berrypod_web/live/admin/navigation.ex index 523271c..e7f1871 100644 --- a/lib/berrypod_web/live/admin/navigation.ex +++ b/lib/berrypod_web/live/admin/navigation.ex @@ -1,40 +1,101 @@ defmodule BerrypodWeb.Admin.Navigation do use BerrypodWeb, :live_view - alias Berrypod.{Pages, Settings} + alias Berrypod.{Pages, Products, Settings} alias BerrypodWeb.ThemeHook + # System pages that are always available + @system_pages [ + %{href: "/", label: "Home"}, + %{href: "/about", label: "About"}, + %{href: "/contact", label: "Contact"}, + %{href: "/cart", label: "Cart"}, + %{href: "/search", label: "Search"}, + %{href: "/delivery", label: "Delivery"}, + %{href: "/privacy", label: "Privacy policy"}, + %{href: "/terms", label: "Terms & conditions"} + ] + @impl true def mount(_params, _session, socket) do header_items = load_nav("header_nav", ThemeHook.default_header_nav()) footer_items = load_nav("footer_nav", ThemeHook.default_footer_nav()) - custom_pages = Pages.list_custom_pages() {:ok, socket |> assign(:page_title, "Navigation") |> assign(:header_items, header_items) |> assign(:footer_items, footer_items) - |> assign(:custom_pages, custom_pages) - |> assign(:dirty, false)} + |> assign(:dirty, false) + |> assign(:save_status, :idle) + |> assign(:save_error, nil) + |> assign_link_options()} + end + + defp assign_link_options(socket) do + custom_pages = Pages.list_custom_pages() + categories = Products.list_categories() + + # Build grouped link options + link_options = [ + {"Pages", Enum.map(@system_pages, &{&1.label, &1.href})}, + {"Custom pages", Enum.map(custom_pages, fn p -> {p.title, "/#{p.slug}"} end)}, + {"Collections", Enum.map(categories, fn c -> {c.name, "/collections/#{c.slug}"} end)}, + {"Other", [{"External URL", "external"}]} + ] + + # Filter out empty groups + link_options = Enum.reject(link_options, fn {_label, opts} -> opts == [] end) + + socket + |> assign(:link_options, link_options) + |> assign(:custom_pages, custom_pages) + |> assign(:categories, categories) end # ── Item manipulation ────────────────────────────────────────── @impl true - def handle_event("update_item", params, socket) do + def handle_event("nav_item_change", params, socket) do section = params["section"] index = String.to_integer(params["index"]) - field = params["field"] - value = params["value"] + label = params["label"] + dest = params["dest"] + external_url = params["external_url"] items = get_items(socket, section) item = Enum.at(items, index) if item do - updated = Map.put(item, field, value) + updated = + cond do + # User selected "External URL" + dest == "external" -> + item + |> Map.put("label", label) + |> Map.put("href", external_url || "") + |> Map.put("external", true) + + # User selected an internal link + dest != nil && dest != "" -> + # Auto-fill label if it was empty and user just selected a destination + auto_label = + if label == "" || label == item["label"], + do: label_for_href(dest, socket), + else: label + + item + |> Map.put("label", if(label == "", do: auto_label, else: label)) + |> Map.put("href", dest) + |> Map.delete("external") + + # Just updating the label + true -> + Map.put(item, "label", label) + end + items = List.replace_at(items, index, updated) - {:noreply, put_items(socket, section, items)} + {:noreply, put_items(socket, section, items) |> clear_save_status()} else {:noreply, socket} end @@ -43,7 +104,7 @@ defmodule BerrypodWeb.Admin.Navigation do def handle_event("remove_item", %{"section" => section, "index" => index_str}, socket) do index = String.to_integer(index_str) items = get_items(socket, section) |> List.delete_at(index) - {:noreply, put_items(socket, section, items)} + {:noreply, put_items(socket, section, items) |> clear_save_status()} end def handle_event( @@ -58,39 +119,37 @@ defmodule BerrypodWeb.Admin.Navigation do if target >= 0 and target < length(items) do item = Enum.at(items, index) items = items |> List.delete_at(index) |> List.insert_at(target, item) - {:noreply, put_items(socket, section, items)} + {:noreply, put_items(socket, section, items) |> clear_save_status()} else {:noreply, socket} end end def handle_event("add_item", %{"section" => section}, socket) do + # Add a new empty item - user will pick destination from dropdown items = get_items(socket, section) ++ [%{"label" => "", "href" => ""}] - {:noreply, put_items(socket, section, items)} + {:noreply, put_items(socket, section, items) |> clear_save_status()} end - def handle_event("add_page", %{"section" => section, "slug" => slug}, socket) do - page = Enum.find(socket.assigns.custom_pages, &(&1.slug == slug)) - - if page do - item = %{"label" => page.title, "href" => "/#{page.slug}", "slug" => page.slug} - items = get_items(socket, section) ++ [item] - {:noreply, put_items(socket, section, items)} - else - {:noreply, socket} - end - end - - # ── Save / reset ─────────────────────────────────────────────── - def handle_event("save", _params, socket) do - Settings.put_setting("header_nav", socket.assigns.header_items, "json") - Settings.put_setting("footer_nav", socket.assigns.footer_items, "json") + all_items = socket.assigns.header_items ++ socket.assigns.footer_items + errors = validate_nav_items(all_items) - {:noreply, - socket - |> assign(:dirty, false) - |> put_flash(:info, "Navigation saved")} + if errors == [] do + Settings.put_setting("header_nav", socket.assigns.header_items, "json") + Settings.put_setting("footer_nav", socket.assigns.footer_items, "json") + + {:noreply, + socket + |> assign(:dirty, false) + |> assign(:save_status, :saved) + |> assign(:save_error, nil)} + else + {:noreply, + socket + |> assign(:save_status, :error) + |> assign(:save_error, Enum.join(errors, ". "))} + end end def handle_event("reset_defaults", _params, socket) do @@ -98,7 +157,79 @@ defmodule BerrypodWeb.Admin.Navigation do socket |> assign(:header_items, ThemeHook.default_header_nav()) |> assign(:footer_items, ThemeHook.default_footer_nav()) - |> assign(:dirty, true)} + |> assign(:dirty, true) + |> clear_save_status()} + end + + # ── Private helpers for events ─────────────────────────────────── + + defp label_for_href(href, socket) do + # Check system pages + case Enum.find(@system_pages, &(&1.href == href)) do + %{label: label} -> + label + + nil -> + # Check custom pages + case Enum.find(socket.assigns.custom_pages, &("/#{&1.slug}" == href)) do + %{title: title} -> + title + + nil -> + # Check collections + case Enum.find(socket.assigns.categories, &("/collections/#{&1.slug}" == href)) do + %{name: name} -> name + nil -> "" + end + end + end + end + + defp validate_nav_items(items) do + items + |> Enum.with_index() + |> Enum.flat_map(fn {item, _idx} -> + cond do + # Empty label + item["label"] == "" || item["label"] == nil -> + ["All links need a label"] + + # Empty href + item["href"] == "" || item["href"] == nil -> + ["\"#{item["label"]}\" needs a destination"] + + # External URL validation + item["external"] == true || is_external_url?(item["href"]) -> + if valid_url?(item["href"]) do + [] + else + ["\"#{item["label"]}\" has an invalid URL"] + end + + true -> + [] + end + end) + |> Enum.uniq() + end + + defp valid_url?(url) when is_binary(url) do + case URI.parse(url) do + %URI{scheme: scheme, host: host} + when scheme in ["http", "https"] and is_binary(host) and host != "" -> + true + + _ -> + false + end + end + + defp valid_url?(_), do: false + + defp clear_save_status(socket) do + socket + |> assign(:save_status, :idle) + |> assign(:save_error, nil) end # ── Render ───────────────────────────────────────────────────── @@ -106,44 +237,59 @@ defmodule BerrypodWeb.Admin.Navigation do @impl true def render(assigns) do ~H""" - <.header> - Navigation - <:subtitle>Configure the links in your shop header and footer. - +