redirect /admin/theme to on-site editor at /?edit=theme
All checks were successful
deploy / deploy (push) Successful in 1m41s
All checks were successful
deploy / deploy (push) Successful in 1m41s
- Phase 5 was already implemented (URL mode activation via ?edit param) - Phase 6: Add RedirectController to redirect /admin/theme → /?edit=theme - Update admin sidebar and dashboard links to point directly to /?edit=theme - Delete old Admin.Theme.Index LiveView and template (no longer needed) - Update tests for new redirect behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
378b3fdb6b
commit
6f0b7f4f63
@ -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)
|
||||
|
||||
@ -143,10 +143,7 @@
|
||||
</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.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
|
||||
</.link>
|
||||
</li>
|
||||
|
||||
@ -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)
|
||||
|
||||
10
lib/berrypod_web/controllers/redirect_controller.ex
Normal file
10
lib/berrypod_web/controllers/redirect_controller.ex
Normal file
@ -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
|
||||
@ -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>
|
||||
<.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
|
||||
</.link>
|
||||
</div>
|
||||
@ -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."
|
||||
},
|
||||
%{
|
||||
|
||||
@ -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"<BerrypodWeb.PageRenderer.render_page {assigns} />"
|
||||
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
|
||||
@ -1,651 +0,0 @@
|
||||
<div class="theme-layout">
|
||||
<!-- Controls Sidebar -->
|
||||
<div
|
||||
id="theme-sidebar"
|
||||
class={[
|
||||
"theme-sidebar",
|
||||
if(@sidebar_collapsed,
|
||||
do: "theme-sidebar-collapsed",
|
||||
else: "theme-sidebar-expanded"
|
||||
)
|
||||
]}
|
||||
>
|
||||
<!-- Collapsed state: just show expand button -->
|
||||
<%= if @sidebar_collapsed do %>
|
||||
<div class="theme-sidebar-collapsed-inner">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_sidebar"
|
||||
class="theme-collapse-btn"
|
||||
aria-label="Expand sidebar"
|
||||
aria-expanded="false"
|
||||
aria-controls="theme-sidebar"
|
||||
>
|
||||
<svg
|
||||
class="theme-collapse-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% else %>
|
||||
<.link href={~p"/admin"} class="theme-back-link">
|
||||
<.icon name="hero-arrow-left-mini" class="size-4" /> Admin
|
||||
</.link>
|
||||
|
||||
<div :if={@from_checklist} class="admin-checklist-banner">
|
||||
<.icon name="hero-clipboard-document-check" class="size-5 admin-checklist-banner-icon" />
|
||||
<span class="admin-checklist-banner-text">
|
||||
You're customising your theme.
|
||||
</span>
|
||||
<.link navigate={~p"/admin"} class="admin-link admin-checklist-banner-link">
|
||||
← Back to checklist
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="theme-header">
|
||||
<div class="admin-fill">
|
||||
<h1 class="theme-title">Theme</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="toggle_sidebar"
|
||||
class="theme-collapse-btn"
|
||||
aria-label="Collapse sidebar"
|
||||
aria-expanded="true"
|
||||
aria-controls="theme-sidebar"
|
||||
>
|
||||
<svg
|
||||
class="theme-collapse-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Site Name -->
|
||||
<div class="theme-section">
|
||||
<label class="theme-section-label">Shop name</label>
|
||||
<form phx-change="update_setting" phx-value-field="site_name">
|
||||
<input
|
||||
type="text"
|
||||
name="site_name"
|
||||
value={@site_name}
|
||||
placeholder="Your shop name"
|
||||
class="admin-input admin-input-lg"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Branding Section -->
|
||||
<div class="theme-panel">
|
||||
<span class="theme-section-label">Logo & header</span>
|
||||
|
||||
<div class="admin-stack admin-stack-sm theme-field">
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.show_site_name}
|
||||
phx-click="toggle_setting"
|
||||
phx-value-field="show_site_name"
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-slider-label">Show shop name</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.show_logo}
|
||||
phx-click="toggle_setting"
|
||||
phx-value-field="show_logo"
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-slider-label">Show logo</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Logo Upload (when logo enabled) -->
|
||||
<%= if @theme_settings.show_logo do %>
|
||||
<div class="theme-subsection">
|
||||
<span class="theme-slider-label theme-block-label">
|
||||
Upload logo (SVG or PNG)
|
||||
</span>
|
||||
<div class="admin-row admin-row-lg">
|
||||
<form phx-change="noop" phx-submit="noop" class="admin-fill">
|
||||
<label class="theme-upload-label">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.logo_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
<%= if @logo_image do %>
|
||||
<div class="theme-thumb theme-thumb-logo">
|
||||
<img
|
||||
src={"/image_cache/#{@logo_image.id}.webp"}
|
||||
alt={@site_name}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_logo"
|
||||
class="theme-remove-btn"
|
||||
title="Remove logo"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= for entry <- @uploads.logo_upload.entries do %>
|
||||
<div class="theme-progress">
|
||||
<div class="theme-progress-bar">
|
||||
<div
|
||||
class="theme-progress-fill"
|
||||
style={"width: #{entry.progress}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="admin-text-secondary">{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="logo_upload"
|
||||
class="theme-upload-cancel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.logo_upload, entry) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.logo_upload) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
|
||||
<!-- Logo Size Slider -->
|
||||
<%= if @logo_image do %>
|
||||
<form
|
||||
phx-change="update_setting"
|
||||
phx-value-field="logo_size"
|
||||
class="theme-subfield"
|
||||
>
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Logo size</span>
|
||||
<span class="theme-slider-value">{@theme_settings.logo_size}px</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="24"
|
||||
max="120"
|
||||
value={@theme_settings.logo_size}
|
||||
name="logo_size"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<!-- SVG Recolor Toggle (only for SVG logos) -->
|
||||
<%= if @logo_image.is_svg do %>
|
||||
<div class="theme-subfield">
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.logo_recolor}
|
||||
phx-click="update_setting"
|
||||
phx-value-field="logo_recolor"
|
||||
phx-value-setting_value={
|
||||
if @theme_settings.logo_recolor, do: "false", else: "true"
|
||||
}
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-slider-label">Recolour logo</span>
|
||||
</label>
|
||||
|
||||
<%= if @theme_settings.logo_recolor do %>
|
||||
<form
|
||||
id="logo-color-form"
|
||||
phx-change="update_color"
|
||||
phx-value-field="logo_color"
|
||||
phx-hook="ColorSync"
|
||||
class="theme-color-row theme-subfield-sm"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
name="value"
|
||||
value={@theme_settings.logo_color}
|
||||
class="theme-color-swatch theme-color-swatch-sm"
|
||||
/>
|
||||
<span class="theme-color-value">{@theme_settings.logo_color}</span>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Site Icon / Favicon -->
|
||||
<div class="theme-panel">
|
||||
<label class="theme-section-label">Site icon</label>
|
||||
<p class="admin-text-tertiary theme-field">
|
||||
Your icon appears in browser tabs and on home screens.
|
||||
</p>
|
||||
|
||||
<!-- Use logo as icon toggle -->
|
||||
<label class="admin-toggle-label theme-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.use_logo_as_icon}
|
||||
phx-click="toggle_setting"
|
||||
phx-value-field="use_logo_as_icon"
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-slider-label">Use logo as favicon</span>
|
||||
</label>
|
||||
|
||||
<!-- Icon upload (only when not using logo) -->
|
||||
<%= if !@theme_settings.use_logo_as_icon do %>
|
||||
<div class="admin-separator">
|
||||
<span class="theme-slider-label theme-block-label">
|
||||
Upload icon (PNG or SVG, 512×512+)
|
||||
</span>
|
||||
<div class="admin-row admin-row-lg">
|
||||
<form phx-change="noop" phx-submit="noop" class="admin-fill">
|
||||
<label class="theme-upload-label">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.icon_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
<%= if @icon_image do %>
|
||||
<div class="theme-thumb theme-thumb-icon">
|
||||
<%= if @icon_image.is_svg do %>
|
||||
<img
|
||||
src={"/images/#{@icon_image.id}/recolored/000000"}
|
||||
alt="Current icon"
|
||||
/>
|
||||
<% else %>
|
||||
<img
|
||||
src={"/image_cache/#{@icon_image.id}.webp"}
|
||||
alt="Current icon"
|
||||
/>
|
||||
<% end %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_icon"
|
||||
class="theme-remove-btn"
|
||||
title="Remove icon"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= for entry <- @uploads.icon_upload.entries do %>
|
||||
<div class="theme-progress">
|
||||
<div class="theme-progress-bar">
|
||||
<div
|
||||
class="theme-progress-fill"
|
||||
style={"width: #{entry.progress}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="admin-text-secondary">{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="icon_upload"
|
||||
class="theme-upload-cancel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.icon_upload, entry) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.icon_upload) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Short name -->
|
||||
<div class="theme-subfield">
|
||||
<form phx-change="update_setting" phx-value-field="favicon_short_name">
|
||||
<label class="theme-slider-label theme-block-label">
|
||||
Short name <span class="admin-text-tertiary">— appears under home screen icon</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="favicon_short_name"
|
||||
value={@theme_settings.favicon_short_name}
|
||||
placeholder={String.slice(@site_name, 0, 12)}
|
||||
maxlength="12"
|
||||
class="admin-input admin-input-sm"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Icon background colour -->
|
||||
<div class="theme-subfield">
|
||||
<form
|
||||
id="icon-bg-color-form"
|
||||
phx-change="update_color"
|
||||
phx-value-field="icon_background_color"
|
||||
phx-hook="ColorSync"
|
||||
class="theme-color-row"
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
name="value"
|
||||
value={@theme_settings.icon_background_color}
|
||||
class="theme-color-swatch theme-color-swatch-sm"
|
||||
/>
|
||||
<div>
|
||||
<span class="theme-slider-label theme-block-label">Icon background</span>
|
||||
<span class="theme-slider-value">
|
||||
{@theme_settings.icon_background_color}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Background Toggle -->
|
||||
<div class="theme-section">
|
||||
<label class="admin-toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={@theme_settings.header_background_enabled}
|
||||
phx-click="update_setting"
|
||||
phx-value-field="header_background_enabled"
|
||||
phx-value-setting_value={
|
||||
if @theme_settings.header_background_enabled, do: "false", else: "true"
|
||||
}
|
||||
class="admin-toggle admin-toggle-sm"
|
||||
/>
|
||||
<span class="theme-check-text">
|
||||
Header background image
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Header Image Upload (only when enabled) -->
|
||||
<%= if @theme_settings.header_background_enabled do %>
|
||||
<div class="theme-panel">
|
||||
<span class="theme-slider-label theme-block-label">
|
||||
Upload header image
|
||||
</span>
|
||||
<form phx-change="noop" phx-submit="noop">
|
||||
<label class="theme-upload-label">
|
||||
<span>Choose file...</span>
|
||||
<.live_file_input upload={@uploads.header_upload} class="hidden" />
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<%= if @header_image do %>
|
||||
<div class="theme-thumb theme-thumb-cover theme-thumb-header">
|
||||
<img
|
||||
src={"/image_cache/#{@header_image.id}.webp"}
|
||||
alt=""
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="remove_header"
|
||||
class="theme-remove-btn"
|
||||
title="Remove header background"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%= if @header_contrast_warning != :ok do %>
|
||||
<div class="theme-contrast-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||
<div>
|
||||
<strong>
|
||||
<%= if @header_contrast_warning == :poor do %>
|
||||
Text may be hard to read
|
||||
<% else %>
|
||||
Text contrast could be better
|
||||
<% end %>
|
||||
</strong>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Header Image Controls -->
|
||||
<div class="admin-stack admin-stack-md theme-subfield">
|
||||
<form phx-change="update_setting" phx-value-field="header_zoom">
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Zoom</span>
|
||||
<span class="theme-slider-value">{@theme_settings.header_zoom}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="100"
|
||||
max="200"
|
||||
value={@theme_settings.header_zoom}
|
||||
name="header_zoom"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
<%= if @theme_settings.header_zoom > 100 do %>
|
||||
<form phx-change="update_setting" phx-value-field="header_position_x">
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Horizontal position</span>
|
||||
<span class="theme-slider-value">{@theme_settings.header_position_x}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={@theme_settings.header_position_x}
|
||||
name="header_position_x"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
<form phx-change="update_setting" phx-value-field="header_position_y">
|
||||
<div class="theme-slider-header">
|
||||
<span class="theme-slider-label">Vertical position</span>
|
||||
<span class="theme-slider-value">{@theme_settings.header_position_y}%</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={@theme_settings.header_position_y}
|
||||
name="header_position_y"
|
||||
class="admin-range"
|
||||
/>
|
||||
</form>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= for entry <- @uploads.header_upload.entries do %>
|
||||
<div class="theme-progress">
|
||||
<div class="theme-progress-bar">
|
||||
<div
|
||||
class="theme-progress-fill"
|
||||
style={"width: #{entry.progress}%"}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<span class="admin-text-secondary">{entry.progress}%</span>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel_upload"
|
||||
phx-value-ref={entry.ref}
|
||||
phx-value-upload="header_upload"
|
||||
class="theme-upload-cancel"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= for err <- upload_errors(@uploads.header_upload, entry) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.header_upload) do %>
|
||||
<p class="theme-error-text">{error_to_string(err)}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Presets Section -->
|
||||
<.preset_grid
|
||||
presets={@presets_with_descriptions}
|
||||
active_preset={@active_preset}
|
||||
event_prefix=""
|
||||
label="Start with a preset"
|
||||
/>
|
||||
|
||||
<!-- Accent Colors -->
|
||||
<.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 Section -->
|
||||
<.customise_accordion
|
||||
theme_settings={@theme_settings}
|
||||
customise_open={@customise_open}
|
||||
event_prefix=""
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Preview Area -->
|
||||
<div class="theme-preview-area">
|
||||
<div class="theme-preview-container">
|
||||
<!-- Preview Page Switcher -->
|
||||
<div class="theme-preview-tabs">
|
||||
<%= 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 %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="change_preview_page"
|
||||
phx-value-page={page_name}
|
||||
class={[
|
||||
"theme-preview-tab",
|
||||
@preview_page == page_name && "theme-preview-tab-active"
|
||||
]}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Browser Chrome -->
|
||||
<div class="theme-browser-chrome">
|
||||
<div class="theme-browser-dots">
|
||||
<div class="theme-browser-dot theme-browser-dot-close"></div>
|
||||
<div class="theme-browser-dot theme-browser-dot-min"></div>
|
||||
<div class="theme-browser-dot theme-browser-dot-max"></div>
|
||||
</div>
|
||||
<div class="theme-browser-url">
|
||||
<svg
|
||||
class="theme-browser-url-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
||||
</svg>
|
||||
<span class="theme-browser-url-text truncate">
|
||||
{@site_name |> String.downcase() |> String.replace(" ", "")}.myshopify.com
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Frame -->
|
||||
<div
|
||||
class="themed theme-preview-frame"
|
||||
data-mood={@theme_settings.mood}
|
||||
data-typography={@theme_settings.typography}
|
||||
data-shape={@theme_settings.shape}
|
||||
data-density={@theme_settings.density}
|
||||
data-grid={@theme_settings.grid_columns}
|
||||
data-header={@theme_settings.header_layout}
|
||||
data-sticky={to_string(@theme_settings.sticky_header)}
|
||||
data-layout={@theme_settings.layout_width}
|
||||
data-shadow={@theme_settings.card_shadow}
|
||||
data-button-style={@theme_settings.button_style}
|
||||
>
|
||||
<style>
|
||||
/* All font faces for theme switching */
|
||||
<%= Phoenix.HTML.raw(Berrypod.Theme.Fonts.generate_all_font_faces(
|
||||
&BerrypodWeb.Endpoint.static_path/1
|
||||
)) %>
|
||||
/* Generated theme CSS */
|
||||
<%= Phoenix.HTML.raw(@generated_css) %>
|
||||
</style>
|
||||
|
||||
<.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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 =~ "<h1 class=\"theme-title\">Theme</h1>"
|
||||
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
|
||||
|
||||
@ -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/<div[^>]*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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user