diff --git a/PROGRESS.md b/PROGRESS.md index 9d994fc..ce90b26 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -92,8 +92,8 @@ Extend the existing page editor (PageEditorHook + editor_sheet) to include theme | 3 | Extract theme editor into reusable component | 3h | done | | 3b | Create settings editor component | 2h | done | | 4 | Image upload handling in hook context | 2h | done | -| 5 | URL-based mode activation (?edit=theme) | 1h | planned | -| 6 | Admin routing redirect | 30m | planned | +| 5 | URL-based mode activation (?edit=theme) | 1h | done | +| 6 | Admin routing redirect | 30m | done | | 7 | Polish and testing | 2h | planned | ### Quick fixes (from usability testing) diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex index b6db09e..c340895 100644 --- a/lib/berrypod_web/components/layouts/admin.html.heex +++ b/lib/berrypod_web/components/layouts/admin.html.heex @@ -143,10 +143,7 @@
  • - <.link - href={~p"/admin/theme"} - class={admin_nav_active?(@current_path, "/admin/theme")} - > + <.link href="/?edit=theme"> <.icon name="hero-paint-brush" class="size-5" /> Theme
  • diff --git a/lib/berrypod_web/components/shop_components/theme_editor.ex b/lib/berrypod_web/components/shop_components/theme_editor.ex index 769981c..b153bb9 100644 --- a/lib/berrypod_web/components/shop_components/theme_editor.ex +++ b/lib/berrypod_web/components/shop_components/theme_editor.ex @@ -1,8 +1,6 @@ defmodule BerrypodWeb.ShopComponents.ThemeEditor do @moduledoc """ - Shared theme editor components used in both: - - Admin theme page (`/admin/theme`) - - On-site editor panel (page editor Theme tab) + Theme editor components for the on-site editor panel (page editor Theme tab). Components render settings controls that emit standard events: - `update_setting` / `theme_update_setting` (phx-click/phx-change) diff --git a/lib/berrypod_web/controllers/redirect_controller.ex b/lib/berrypod_web/controllers/redirect_controller.ex new file mode 100644 index 0000000..54f3b4c --- /dev/null +++ b/lib/berrypod_web/controllers/redirect_controller.ex @@ -0,0 +1,10 @@ +defmodule BerrypodWeb.RedirectController do + use BerrypodWeb, :controller + + @doc """ + Redirects /admin/theme to the on-site theme editor. + """ + def theme(conn, _params) do + redirect(conn, to: "/?edit=theme") + end +end diff --git a/lib/berrypod_web/live/admin/dashboard.ex b/lib/berrypod_web/live/admin/dashboard.ex index 095e62a..98aed61 100644 --- a/lib/berrypod_web/live/admin/dashboard.ex +++ b/lib/berrypod_web/live/admin/dashboard.ex @@ -58,7 +58,7 @@ defmodule BerrypodWeb.Admin.Dashboard do <.link href={~p"/"} class="admin-btn admin-btn-primary"> <.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop - <.link navigate={~p"/admin/theme"} class="admin-btn admin-btn-secondary"> + <.link href="/?edit=theme" class="admin-btn admin-btn-secondary"> <.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme @@ -273,7 +273,7 @@ defmodule BerrypodWeb.Admin.Dashboard do %{ key: :theme_customised, label: "Customise your theme", - href: "/admin/theme?from=checklist", + href: "/?edit=theme", hint: "Upload your logo, pick your colours, and choose a font that matches your brand." }, %{ diff --git a/lib/berrypod_web/live/admin/theme/index.ex b/lib/berrypod_web/live/admin/theme/index.ex deleted file mode 100644 index c893484..0000000 --- a/lib/berrypod_web/live/admin/theme/index.ex +++ /dev/null @@ -1,630 +0,0 @@ -defmodule BerrypodWeb.Admin.Theme.Index do - use BerrypodWeb, :live_view - - alias Berrypod.{Pages, Settings} - alias Berrypod.Media - alias Berrypod.Theme.{Contrast, CSSGenerator, Presets, PreviewData} - alias Berrypod.Workers.FaviconGeneratorWorker - - @impl true - def mount(_params, _session, socket) do - theme_settings = Settings.get_theme_settings() - generated_css = CSSGenerator.generate(theme_settings) - active_preset = Presets.detect_preset(theme_settings) - - preview_data = %{ - products: PreviewData.products(), - cart_items: PreviewData.cart_items(), - testimonials: PreviewData.testimonials(), - categories: PreviewData.categories() - } - - logo_image = Media.get_logo() - header_image = Media.get_header() - icon_image = Media.get_icon() - - socket = - socket - |> assign(:theme_settings, theme_settings) - |> assign(:site_name, Settings.site_name()) - |> assign(:site_description, Settings.site_description()) - |> assign(:generated_css, generated_css) - |> assign(:preview_page, :home) - |> assign(:presets_with_descriptions, Presets.all_with_descriptions()) - |> assign(:active_preset, active_preset) - |> assign(:preview_data, preview_data) - |> assign(:logo_image, logo_image) - |> assign(:header_image, header_image) - |> assign(:icon_image, icon_image) - |> assign(:customise_open, false) - |> assign(:sidebar_collapsed, false) - |> assign(:cart_drawer_open, false) - |> compute_header_contrast_warning() - |> allow_upload(:logo_upload, - accept: ~w(.png .jpg .jpeg .webp .svg), - max_entries: 1, - max_file_size: 2_000_000, - auto_upload: true, - progress: &handle_progress/3 - ) - |> allow_upload(:header_upload, - accept: ~w(.png .jpg .jpeg .webp), - max_entries: 1, - max_file_size: 5_000_000, - auto_upload: true, - progress: &handle_progress/3 - ) - |> allow_upload(:icon_upload, - accept: ~w(.png .jpg .jpeg .webp .svg), - max_entries: 1, - max_file_size: 5_000_000, - auto_upload: true, - progress: &handle_progress/3 - ) - - {:ok, assign(socket, :from_checklist, false)} - end - - @impl true - def handle_params(params, _uri, socket) do - {:noreply, assign(socket, :from_checklist, params["from"] == "checklist")} - end - - defp handle_progress(:logo_upload, entry, socket) do - if entry.done? do - consume_uploaded_entries(socket, :logo_upload, fn %{path: path}, entry -> - case Media.upload_from_entry(path, entry, "logo") do - {:ok, image} -> - Settings.update_theme_settings(%{logo_image_id: image.id}) - {:ok, image} - - {:error, _} = error -> - error - end - end) - |> case do - [image | _] -> - # Trigger favicon generation if using logo as icon - if socket.assigns.theme_settings.use_logo_as_icon do - enqueue_favicon_generation(image.id) - end - - {:noreply, assign(socket, :logo_image, image)} - - _ -> - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - defp handle_progress(:header_upload, entry, socket) do - if entry.done? do - consume_uploaded_entries(socket, :header_upload, fn %{path: path}, entry -> - case Media.upload_from_entry(path, entry, "header") do - {:ok, image} -> - Settings.update_theme_settings(%{header_image_id: image.id}) - {:ok, image} - - {:error, _} = error -> - error - end - end) - |> case do - [image | _] -> - socket = - socket - |> assign(:header_image, image) - |> compute_header_contrast_warning() - - {:noreply, socket} - - _ -> - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - defp handle_progress(:icon_upload, entry, socket) do - if entry.done? do - consume_uploaded_entries(socket, :icon_upload, fn %{path: path}, entry -> - case Media.upload_from_entry(path, entry, "icon") do - {:ok, image} -> - Settings.update_theme_settings(%{icon_image_id: image.id}) - {:ok, image} - - {:error, _} = error -> - error - end - end) - |> case do - [image | _] -> - enqueue_favicon_generation(image.id) - {:noreply, assign(socket, :icon_image, image)} - - _ -> - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("apply_preset", %{"preset" => preset_name}, socket) do - preset_atom = String.to_existing_atom(preset_name) - - case Settings.apply_preset(preset_atom) do - {:ok, theme_settings} -> - generated_css = CSSGenerator.generate(theme_settings) - - socket = - socket - |> assign(:theme_settings, theme_settings) - |> assign(:generated_css, generated_css) - |> assign(:active_preset, preset_atom) - |> compute_header_contrast_warning() - |> put_flash(:info, "Applied #{preset_name} preset") - - {:noreply, socket} - - {:error, _} -> - {:noreply, put_flash(socket, :error, "Failed to apply preset")} - end - end - - @impl true - def handle_event("change_preview_page", %{"page" => page_name}, socket) do - page_atom = String.to_existing_atom(page_name) - - socket = - socket - |> assign(:preview_page, page_atom) - |> push_event("scroll-preview-top", %{}) - - {:noreply, socket} - end - - # Settings stored outside the theme JSON - @standalone_settings ~w(site_name site_description) - - @impl true - def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) - when field in @standalone_settings do - Settings.put_setting(field, value, "string") - {:noreply, assign(socket, String.to_existing_atom(field), value)} - end - - @impl true - def handle_event("update_setting", %{"field" => field, "setting_value" => value}, socket) do - field_atom = String.to_existing_atom(field) - attrs = %{field_atom => value} - - case Settings.update_theme_settings(attrs) do - {:ok, theme_settings} -> - generated_css = CSSGenerator.generate(theme_settings) - active_preset = Presets.detect_preset(theme_settings) - - socket = - socket - |> assign(:theme_settings, theme_settings) - |> assign(:generated_css, generated_css) - |> assign(:active_preset, active_preset) - |> maybe_recompute_contrast_warning(field) - - {:noreply, socket} - - {:error, _} -> - {:noreply, socket} - end - end - - @impl true - def handle_event("update_setting", %{"field" => field} = params, socket) - when field in @standalone_settings do - value = params[field] - - if value do - Settings.put_setting(field, value, "string") - {:noreply, assign(socket, String.to_existing_atom(field), value)} - else - {:noreply, socket} - end - end - - @impl true - def handle_event("update_setting", %{"field" => field} = params, socket) do - # For phx-change events from select/input elements, the value comes from the name attribute - value = params[field] || params["#{field}_text"] || params["value"] - - if value do - field_atom = String.to_existing_atom(field) - attrs = %{field_atom => value} - - case Settings.update_theme_settings(attrs) do - {:ok, theme_settings} -> - generated_css = CSSGenerator.generate(theme_settings) - active_preset = Presets.detect_preset(theme_settings) - - socket = - socket - |> assign(:theme_settings, theme_settings) - |> assign(:generated_css, generated_css) - |> assign(:active_preset, active_preset) - |> maybe_recompute_contrast_warning(field) - - {:noreply, socket} - - {:error, _} -> - {:noreply, socket} - end - else - {:noreply, socket} - end - end - - @impl true - def handle_event("update_color", %{"field" => field, "value" => value}, socket) do - field_atom = String.to_existing_atom(field) - attrs = %{field_atom => value} - - case Settings.update_theme_settings(attrs) do - {:ok, theme_settings} -> - generated_css = CSSGenerator.generate(theme_settings) - active_preset = Presets.detect_preset(theme_settings) - - socket = - socket - |> assign(:theme_settings, theme_settings) - |> assign(:generated_css, generated_css) - |> assign(:active_preset, active_preset) - - {:noreply, socket} - - {:error, _} -> - {:noreply, socket} - end - end - - @impl true - def handle_event("toggle_setting", %{"field" => field}, socket) do - field_atom = String.to_existing_atom(field) - current_value = Map.get(socket.assigns.theme_settings, field_atom) - new_value = !current_value - - # Prevent turning off show_site_name when there's no logo to display - if field_atom == :show_site_name && new_value == false && !has_valid_logo?(socket) do - {:noreply, put_flash(socket, :error, "Upload a logo first to hide the shop name")} - else - attrs = %{field_atom => new_value} - - case Settings.update_theme_settings(attrs) do - {:ok, theme_settings} -> - generated_css = CSSGenerator.generate(theme_settings) - active_preset = Presets.detect_preset(theme_settings) - - # Trigger favicon regeneration when the icon source changes - if field_atom == :use_logo_as_icon do - maybe_enqueue_favicon_from_settings(theme_settings, socket.assigns) - end - - socket = - socket - |> assign(:theme_settings, theme_settings) - |> assign(:generated_css, generated_css) - |> assign(:active_preset, active_preset) - |> maybe_recompute_contrast_warning(field) - - {:noreply, socket} - - {:error, _} -> - {:noreply, socket} - end - end - end - - @impl true - def handle_event("save_theme", _params, socket) do - socket = put_flash(socket, :info, "Theme saved successfully") - {:noreply, socket} - end - - @impl true - def handle_event("update_image_alt", %{"image-id" => image_id, "alt" => alt}, socket) do - case Media.get_image(image_id) do - nil -> - {:noreply, socket} - - image -> - {:ok, updated} = Media.update_image_metadata(image, %{alt: alt}) - - # Refresh the relevant assign so the template sees the new alt text - socket = - cond do - socket.assigns.logo_image && socket.assigns.logo_image.id == image_id -> - assign(socket, :logo_image, updated) - - socket.assigns.header_image && socket.assigns.header_image.id == image_id -> - assign(socket, :header_image, updated) - - socket.assigns[:icon_image] && socket.assigns.icon_image && - socket.assigns.icon_image.id == image_id -> - assign(socket, :icon_image, updated) - - true -> - socket - end - - {:noreply, socket} - end - end - - @impl true - def handle_event("remove_logo", _params, socket) do - if logo = socket.assigns.logo_image do - Media.delete_image(logo) - end - - # Re-enable shop name when removing logo to ensure header isn't empty - {:ok, theme_settings} = - Settings.update_theme_settings(%{logo_image_id: nil, show_site_name: true}) - - generated_css = CSSGenerator.generate(theme_settings) - - socket = - socket - |> assign(:logo_image, nil) - |> assign(:theme_settings, theme_settings) - |> assign(:generated_css, generated_css) - |> put_flash(:info, "Logo removed") - - {:noreply, socket} - end - - @impl true - def handle_event("remove_header", _params, socket) do - if header = socket.assigns.header_image do - Media.delete_image(header) - end - - Settings.update_theme_settings(%{header_image_id: nil}) - - socket = - socket - |> assign(:header_image, nil) - |> assign(:header_contrast_warning, :ok) - |> put_flash(:info, "Header image removed") - - {:noreply, socket} - end - - @impl true - def handle_event("remove_icon", _params, socket) do - if icon = socket.assigns.icon_image do - Media.delete_image(icon) - end - - Settings.update_theme_settings(%{icon_image_id: nil}) - - socket = - socket - |> assign(:icon_image, nil) - |> put_flash(:info, "Icon removed") - - {:noreply, socket} - end - - @impl true - def handle_event("cancel_upload", %{"ref" => ref, "upload" => upload_name}, socket) do - upload_atom = String.to_existing_atom(upload_name) - {:noreply, cancel_upload(socket, upload_atom, ref)} - end - - @impl true - def handle_event("toggle_customise", _params, socket) do - {:noreply, assign(socket, :customise_open, !socket.assigns.customise_open)} - end - - @impl true - def handle_event("toggle_sidebar", _params, socket) do - {:noreply, assign(socket, :sidebar_collapsed, !socket.assigns.sidebar_collapsed)} - end - - @impl true - def handle_event("open_cart_drawer", _params, socket) do - {:noreply, assign(socket, :cart_drawer_open, true)} - end - - @impl true - def handle_event("close_cart_drawer", _params, socket) do - {:noreply, assign(socket, :cart_drawer_open, false)} - end - - @impl true - def handle_event("noop", _params, socket) do - {:noreply, socket} - end - - defp has_valid_logo?(socket) do - socket.assigns.theme_settings.show_logo && socket.assigns.logo_image != nil - end - - def error_to_string(:too_large), do: "File is too large" - def error_to_string(:too_many_files), do: "Too many files" - def error_to_string(:not_accepted), do: "File type not accepted" - def error_to_string(err), do: inspect(err) - - defp enqueue_favicon_generation(source_image_id) do - %{source_image_id: source_image_id} - |> FaviconGeneratorWorker.new() - |> Oban.insert() - end - - defp maybe_enqueue_favicon_from_settings(theme_settings, assigns) do - source_id = - if theme_settings.use_logo_as_icon do - case assigns.logo_image do - %{id: id} -> id - _ -> nil - end - else - case assigns.icon_image do - %{id: id} -> id - _ -> nil - end - end - - if source_id, do: enqueue_favicon_generation(source_id) - end - - defp preview_assigns(assigns) do - assign(assigns, %{ - mode: :preview, - products: assigns.preview_data.products, - categories: assigns.preview_data.categories, - cart_items: PreviewData.cart_drawer_items(), - cart_count: 2, - cart_subtotal: "£72.00", - header_nav_items: BerrypodWeb.ThemeHook.default_header_nav(), - footer_nav_items: BerrypodWeb.ThemeHook.default_footer_nav() - }) - end - - # Unified preview — loads page definition, applies context, renders via PageRenderer - attr :page, :atom, required: true - attr :preview_data, :map, required: true - attr :theme_settings, :map, required: true - attr :site_name, :string, required: true - attr :logo_image, :any, required: true - attr :header_image, :any, required: true - attr :cart_drawer_open, :boolean, default: false - - defp preview_page(assigns) do - slug = to_string(assigns.page) - page = Pages.get_page(slug) - - assigns = - assigns - |> preview_assigns() - |> assign(:page, page) - |> preview_page_context(slug) - - extra = Pages.load_block_data(page.blocks, assigns) - assigns = assign(assigns, extra) - - ~H"" - end - - # Page-context data needed by specific page types in preview mode - defp preview_page_context(assigns, "pdp") do - product = List.first(assigns.preview_data.products) - option_types = Map.get(product, :option_types) || [] - variants = Map.get(product, :variants) || [] - - {selected_options, selected_variant} = - case variants do - [first | _] -> {first.options, first} - [] -> {%{}, nil} - end - - available_options = - Enum.reduce(option_types, %{}, fn opt, acc -> - values = Enum.map(opt.values, & &1.title) - Map.put(acc, opt.name, values) - end) - - display_price = - if selected_variant, do: selected_variant.price, else: product.cheapest_price - - assigns - |> assign(:product, product) - |> assign(:gallery_images, build_gallery_images(product)) - |> assign(:option_types, option_types) - |> assign(:selected_options, selected_options) - |> assign(:available_options, available_options) - |> assign(:display_price, display_price) - |> assign(:quantity, 1) - |> assign(:option_urls, %{}) - end - - defp preview_page_context(assigns, "cart") do - cart_items = assigns.preview_data.cart_items - - subtotal = - Enum.reduce(cart_items, 0, fn item, acc -> - acc + item.product.cheapest_price * item.quantity - end) - - assigns - |> assign(:cart_page_items, cart_items) - |> assign(:cart_page_subtotal, subtotal) - end - - defp preview_page_context(assigns, "about") do - assign(assigns, :content_blocks, PreviewData.about_content()) - end - - defp preview_page_context(assigns, "delivery") do - assign(assigns, :content_blocks, PreviewData.delivery_content()) - end - - defp preview_page_context(assigns, "privacy") do - assign(assigns, :content_blocks, PreviewData.privacy_content()) - end - - defp preview_page_context(assigns, "terms") do - assign(assigns, :content_blocks, PreviewData.terms_content()) - end - - defp preview_page_context(assigns, "error") do - assign(assigns, %{ - error_code: "404", - error_title: "Page Not Found", - error_description: - "Sorry, we couldn't find the page you're looking for. Perhaps you've mistyped the URL or the page has been moved." - }) - end - - defp preview_page_context(assigns, _slug), do: assigns - - defp build_gallery_images(product) do - alias Berrypod.Products.ProductImage - - (Map.get(product, :images) || []) - |> Enum.sort_by(& &1.position) - |> Enum.map(fn img -> ProductImage.url(img, 1200) end) - |> Enum.reject(&is_nil/1) - |> case do - [] -> [] - urls -> urls - end - end - - # Compute header contrast warning based on image colors and theme mood - defp compute_header_contrast_warning(socket) do - header_image = socket.assigns.header_image - theme_settings = socket.assigns.theme_settings - - warning = - if theme_settings.header_background_enabled && header_image do - text_color = Contrast.text_color_for_mood(theme_settings.mood) - colors = Contrast.parse_dominant_colors(header_image.dominant_colors) - Contrast.analyze_header_contrast(colors, text_color) - else - :ok - end - - assign(socket, :header_contrast_warning, warning) - end - - # Only recompute when mood or header_background_enabled changes - defp maybe_recompute_contrast_warning(socket, field) - when field in ["mood", "header_background_enabled"] do - compute_header_contrast_warning(socket) - end - - defp maybe_recompute_contrast_warning(socket, _field), do: socket -end diff --git a/lib/berrypod_web/live/admin/theme/index.html.heex b/lib/berrypod_web/live/admin/theme/index.html.heex deleted file mode 100644 index 3900a38..0000000 --- a/lib/berrypod_web/live/admin/theme/index.html.heex +++ /dev/null @@ -1,651 +0,0 @@ -
    - -
    - - <%= if @sidebar_collapsed do %> -
    - -
    - <% else %> - <.link href={~p"/admin"} class="theme-back-link"> - <.icon name="hero-arrow-left-mini" class="size-4" /> Admin - - -
    - <.icon name="hero-clipboard-document-check" class="size-5 admin-checklist-banner-icon" /> - - You're customising your theme. - - <.link navigate={~p"/admin"} class="admin-link admin-checklist-banner-link"> - ← Back to checklist - -
    - - -
    -
    -

    Theme

    -
    - -
    - - -
    - -
    - -
    -
    - - -
    - - -
    - - - -
    - - - <%= if @theme_settings.show_logo do %> -
    - - Upload logo (SVG or PNG) - -
    -
    - -
    - <%= if @logo_image do %> - - <% end %> -
    - - <%= for entry <- @uploads.logo_upload.entries do %> -
    -
    -
    -
    -
    - {entry.progress}% - -
    - <%= for err <- upload_errors(@uploads.logo_upload, entry) do %> -

    {error_to_string(err)}

    - <% end %> - <% end %> - - <%= for err <- upload_errors(@uploads.logo_upload) do %> -

    {error_to_string(err)}

    - <% end %> - - - <%= if @logo_image do %> -
    -
    - Logo size - {@theme_settings.logo_size}px -
    - -
    - - - <%= if @logo_image.is_svg do %> -
    - - - <%= if @theme_settings.logo_recolor do %> -
    - - {@theme_settings.logo_color} -
    - <% end %> -
    - <% end %> - <% end %> -
    - <% end %> -
    - - -
    - -

    - Your icon appears in browser tabs and on home screens. -

    - - - - - - <%= if !@theme_settings.use_logo_as_icon do %> -
    - - Upload icon (PNG or SVG, 512×512+) - -
    -
    - -
    - <%= if @icon_image do %> -
    - <%= if @icon_image.is_svg do %> - Current icon - <% else %> - Current icon - <% end %> - -
    - <% end %> -
    - - <%= for entry <- @uploads.icon_upload.entries do %> -
    -
    -
    -
    -
    - {entry.progress}% - -
    - <%= for err <- upload_errors(@uploads.icon_upload, entry) do %> -

    {error_to_string(err)}

    - <% end %> - <% end %> - - <%= for err <- upload_errors(@uploads.icon_upload) do %> -

    {error_to_string(err)}

    - <% end %> -
    - <% end %> - - -
    -
    - - -
    -
    - - -
    -
    - -
    - Icon background - - {@theme_settings.icon_background_color} - -
    -
    -
    -
    - - -
    - -
    - - - <%= if @theme_settings.header_background_enabled do %> -
    - - Upload header image - -
    - -
    - - <%= if @header_image do %> -
    - - -
    - - <%= if @header_contrast_warning != :ok do %> -
    - <.icon name="hero-exclamation-triangle" class="size-5" /> -
    - - <%= if @header_contrast_warning == :poor do %> - Text may be hard to read - <% else %> - Text contrast could be better - <% end %> - -

    - The header text might blend into this background. - Try switching to a - <%= if @theme_settings.mood == "dark" do %> - lighter mood - <% else %> - dark mood - <% end %> - or choosing a different image. -

    -
    -
    - <% end %> - - -
    -
    -
    - Zoom - {@theme_settings.header_zoom}% -
    - -
    - <%= if @theme_settings.header_zoom > 100 do %> -
    -
    - Horizontal position - {@theme_settings.header_position_x}% -
    - -
    -
    -
    - Vertical position - {@theme_settings.header_position_y}% -
    - -
    - <% end %> -
    - <% end %> - - <%= for entry <- @uploads.header_upload.entries do %> -
    -
    -
    -
    -
    - {entry.progress}% - -
    - <%= for err <- upload_errors(@uploads.header_upload, entry) do %> -

    {error_to_string(err)}

    - <% end %> - <% end %> - - <%= for err <- upload_errors(@uploads.header_upload) do %> -

    {error_to_string(err)}

    - <% end %> -
    - <% end %> - - - <.preset_grid - presets={@presets_with_descriptions} - active_preset={@active_preset} - event_prefix="" - label="Start with a preset" - /> - - - <.color_picker - field="accent_color" - label="Accent colour" - value={@theme_settings.accent_color} - event_prefix="" - /> - <.color_picker - field="secondary_accent_color" - label="Hover colour" - value={@theme_settings.secondary_accent_color} - event_prefix="" - /> - <.color_picker - field="sale_color" - label="Sale colour" - value={@theme_settings.sale_color} - event_prefix="" - /> - - - <.customise_accordion - theme_settings={@theme_settings} - customise_open={@customise_open} - event_prefix="" - /> - <% end %> -
    - - -
    -
    - -
    - <%= for {page_name, label} <- [ - {:home, "Home"}, - {:collection, "Collection"}, - {:pdp, "Product"}, - {:cart, "Cart"}, - {:about, "About"}, - {:delivery, "Delivery"}, - {:privacy, "Privacy"}, - {:terms, "Terms"}, - {:contact, "Contact"}, - {:error, "404"} - ] do %> - - <% end %> -
    - - -
    -
    -
    -
    -
    -
    -
    - - - - - - {@site_name |> String.downcase() |> String.replace(" ", "")}.myshopify.com - -
    -
    - - -
    - - - <.preview_page - page={@preview_page} - preview_data={@preview_data} - theme_settings={@theme_settings} - site_name={@site_name} - logo_image={@logo_image} - header_image={@header_image} - cart_drawer_open={@cart_drawer_open} - /> -
    -
    -
    -
    diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 00f3837..34099be 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -183,11 +183,8 @@ defmodule BerrypodWeb.Router do live "/redirects", Admin.Redirects, :index end - # Theme editor: admin root layout but full-screen (no sidebar) - live_session :admin_theme, - on_mount: [{BerrypodWeb.UserAuth, :require_authenticated}] do - live "/theme", Admin.Theme.Index, :index - end + # Theme editor redirects to on-site editing + get "/theme", RedirectController, :theme end # User account settings diff --git a/test/berrypod_web/live/admin/layout_test.exs b/test/berrypod_web/live/admin/layout_test.exs index ae266ac..bc80c21 100644 --- a/test/berrypod_web/live/admin/layout_test.exs +++ b/test/berrypod_web/live/admin/layout_test.exs @@ -19,7 +19,7 @@ defmodule BerrypodWeb.Admin.LayoutTest do assert has_element?(view, ~s(a[href="/admin"]), "Dashboard") assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders") - assert has_element?(view, ~s(a[href="/admin/theme"]), "Theme") + assert has_element?(view, ~s(a[href="/?edit=theme"]), "Theme") assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings") end @@ -45,24 +45,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do end end - describe "theme editor layout" do - setup %{conn: conn, user: user} do - %{conn: log_in_user(conn, user)} - end - - test "does not render sidebar", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/admin/theme") - - refute html =~ "admin-drawer" - end - - test "shows back link to admin", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - assert has_element?(view, ~s(a[href="/admin"]), "Admin") - end - end - describe "admin bar on shop pages" do setup do {:ok, _} = Berrypod.Settings.set_site_live(true) diff --git a/test/berrypod_web/live/admin/theme_test.exs b/test/berrypod_web/live/admin/theme_test.exs index 85eb5ac..696099a 100644 --- a/test/berrypod_web/live/admin/theme_test.exs +++ b/test/berrypod_web/live/admin/theme_test.exs @@ -1,216 +1,24 @@ defmodule BerrypodWeb.Admin.ThemeTest do use BerrypodWeb.ConnCase, async: false - import Phoenix.LiveViewTest import Berrypod.AccountsFixtures - alias Berrypod.Settings - setup do user = user_fixture() %{user: user} end - describe "Index (unauthenticated)" do - test "redirects to login when not authenticated", %{conn: conn} do - {:error, redirect} = live(conn, ~p"/admin/theme") + describe "/admin/theme redirect" do + test "redirects unauthenticated users to login", %{conn: conn} do + conn = get(conn, ~p"/admin/theme") - assert {:redirect, %{to: path}} = redirect - assert path == ~p"/users/log-in" - end - end - - describe "Index (authenticated)" do - setup %{conn: conn, user: user} do - conn = log_in_user(conn, user) - %{conn: conn} + assert redirected_to(conn) == ~p"/users/log-in" end - test "renders theme editor page", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/admin/theme") + test "redirects authenticated users to on-site editor", %{conn: conn, user: user} do + conn = conn |> log_in_user(user) |> get(~p"/admin/theme") - assert html =~ "

    Theme

    " - assert html =~ "preset" - end - - test "displays all 8 presets", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/admin/theme") - - assert html =~ "gallery" - assert html =~ "studio" - assert html =~ "boutique" - assert html =~ "bold" - assert html =~ "playful" - assert html =~ "minimal" - assert html =~ "night" - assert html =~ "classic" - end - - test "displays current theme settings", %{conn: conn} do - {:ok, _settings} = Settings.apply_preset(:gallery) - {:ok, _view, html} = live(conn, ~p"/admin/theme") - - assert html =~ "warm" - assert html =~ "editorial" - assert html =~ "soft" - assert html =~ "spacious" - end - - test "displays generated CSS in preview", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/admin/theme") - - # CSS generator outputs accent colors and layout variables for shop pages - assert html =~ ".themed {" - assert html =~ "--t-accent-h:" - end - - test "applies preset and updates preview", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - html = - view - |> element("button", "gallery") - |> render_click() - - theme_settings = Settings.get_theme_settings() - assert theme_settings.mood == "warm" - assert theme_settings.typography == "editorial" - assert html =~ "warm" - assert html =~ "editorial" - end - - test "switches preview page", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - html = - view - |> element("button", "Collection") - |> render_click() - - assert html =~ "All Products" - end - - test "theme settings are saved when applying a preset", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - # Apply a preset - view - |> element("button", "gallery") - |> render_click() - - # Verify settings were persisted - theme_settings = Settings.get_theme_settings() - assert theme_settings.mood == "warm" - assert theme_settings.typography == "editorial" - end - - test "all preview page buttons are present", %{conn: conn} do - {:ok, _view, html} = live(conn, ~p"/admin/theme") - - assert html =~ "Home" - assert html =~ "Collection" - assert html =~ "Product" - assert html =~ "Cart" - assert html =~ "About" - assert html =~ "Contact" - assert html =~ "404" - end - - test "mood customization buttons work", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - # Click the "dark" mood button - html = - view - |> element("button[phx-value-setting_value='dark']") - |> render_click() - - # Verify the setting was updated - theme_settings = Settings.get_theme_settings() - assert theme_settings.mood == "dark" - assert html =~ "dark" - end - - test "shape customization buttons work", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - # Click the "round" shape button - view - |> element("button[phx-value-setting_value='round']") - |> render_click() - - # Verify the setting was updated - theme_settings = Settings.get_theme_settings() - assert theme_settings.shape == "round" - end - - test "density customization buttons work", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - # Click the "compact" density button - view - |> element("button[phx-value-setting_value='compact']") - |> render_click() - - # Verify the setting was updated - theme_settings = Settings.get_theme_settings() - assert theme_settings.density == "compact" - end - - test "grid columns customization buttons work", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - # Click the "2 columns" grid columns button - view - |> element("button", "2 columns") - |> render_click() - - # Verify the setting was updated - theme_settings = Settings.get_theme_settings() - assert theme_settings.grid_columns == "2" - end - - test "typography customization buttons work", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - # Click the "modern" typography button - view - |> element("button[phx-value-setting_value='modern']") - |> render_click() - - # Verify the setting was updated - theme_settings = Settings.get_theme_settings() - assert theme_settings.typography == "modern" - end - - test "header layout customization buttons work", %{conn: conn} do - {:ok, view, _html} = live(conn, ~p"/admin/theme") - - # Click the "centered" header layout button - view - |> element("button[phx-value-setting_value='centered']") - |> render_click() - - # Verify the setting was updated - theme_settings = Settings.get_theme_settings() - assert theme_settings.header_layout == "centered" - end - - test "CSS regenerates when settings change", %{conn: conn} do - {:ok, view, html} = live(conn, ~p"/admin/theme") - - # Capture initial CSS - initial_css = html - - # Change a setting - new_html = - view - |> element("button[phx-value-setting_value='dark']") - |> render_click() - - # Verify CSS has changed - refute initial_css == new_html - assert new_html =~ "--t-accent-h:" + assert redirected_to(conn) == "/?edit=theme" end end end diff --git a/test/berrypod_web/live/theme_css_consistency_test.exs b/test/berrypod_web/live/theme_css_consistency_test.exs index b66f470..a2605a8 100644 --- a/test/berrypod_web/live/theme_css_consistency_test.exs +++ b/test/berrypod_web/live/theme_css_consistency_test.exs @@ -1,12 +1,11 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do @moduledoc """ - Tests that verify CSS works correctly for both the theme editor - preview and the shop pages using the shared .themed class. + Tests that verify CSS works correctly for shop pages using the .themed class. Architecture: - - Both shop pages and preview use .themed class for shared styles - - Theme editor uses .preview-frame[data-*] selectors for live switching (in admin.css) - - Shop pages get theme values via inline CSS from CSSGenerator (shop.css) + - Shop pages use .themed class for theme-aware styles + - Theme editor is on-site (/?edit=theme) so it uses the same CSS as shop pages + - Shop pages get theme values via inline CSS from CSSGenerator - Component styles use .themed for shared styling (theme-layer2-attributes.css) """ @@ -34,11 +33,12 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do assert html =~ ~r/data-density="/ end - test "theme editor has .themed with data attributes", %{conn: conn, user: user} do + test "on-site theme editor has .themed with data attributes", %{conn: conn, user: user} do conn = log_in_user(conn, user) - {:ok, _view, html} = live(conn, ~p"/admin/theme") + # Theme editor is now on-site at /?edit=theme + {:ok, _view, html} = live(conn, ~p"/?edit=theme") - # Verify themed element exists in preview-frame with theme data attributes + # Verify themed element exists with theme data attributes assert html =~ ~r/]*class="themed/ assert html =~ ~r/data-mood="/ assert html =~ ~r/data-typography="/ @@ -46,26 +46,6 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do assert html =~ ~r/data-density="/ end - test "shop page uses same theme settings as preview", %{conn: conn, user: user} do - # Set a specific theme configuration - {:ok, _settings} = Settings.apply_preset(:night) - - # Check shop page (logged in since site_live is false by default) - conn = log_in_user(conn, user) - {:ok, _view, shop_html} = live(conn, ~p"/") - - # Check preview (already authenticated) - {:ok, _view, preview_html} = live(conn, ~p"/admin/theme") - - # Extract data-mood values from both - [_, shop_mood] = Regex.run(~r/data-mood="([^"]+)"/, shop_html) - [_, preview_mood] = Regex.run(~r/data-mood="([^"]+)"/, preview_html) - - # They should match - assert shop_mood == preview_mood - assert shop_mood == "dark" - end - test "theme settings changes are reflected on shop page", %{conn: conn, user: user} do conn = log_in_user(conn, user)