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 |
|
| 3 | Extract theme editor into reusable component | 3h | done |
|
||||||
| 3b | Create settings editor component | 2h | done |
|
| 3b | Create settings editor component | 2h | done |
|
||||||
| 4 | Image upload handling in hook context | 2h | done |
|
| 4 | Image upload handling in hook context | 2h | done |
|
||||||
| 5 | URL-based mode activation (?edit=theme) | 1h | planned |
|
| 5 | URL-based mode activation (?edit=theme) | 1h | done |
|
||||||
| 6 | Admin routing redirect | 30m | planned |
|
| 6 | Admin routing redirect | 30m | done |
|
||||||
| 7 | Polish and testing | 2h | planned |
|
| 7 | Polish and testing | 2h | planned |
|
||||||
|
|
||||||
### Quick fixes (from usability testing)
|
### Quick fixes (from usability testing)
|
||||||
|
|||||||
@ -143,10 +143,7 @@
|
|||||||
</.link>
|
</.link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<.link
|
<.link href="/?edit=theme">
|
||||||
href={~p"/admin/theme"}
|
|
||||||
class={admin_nav_active?(@current_path, "/admin/theme")}
|
|
||||||
>
|
|
||||||
<.icon name="hero-paint-brush" class="size-5" /> Theme
|
<.icon name="hero-paint-brush" class="size-5" /> Theme
|
||||||
</.link>
|
</.link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
defmodule BerrypodWeb.ShopComponents.ThemeEditor do
|
defmodule BerrypodWeb.ShopComponents.ThemeEditor do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Shared theme editor components used in both:
|
Theme editor components for the on-site editor panel (page editor Theme tab).
|
||||||
- Admin theme page (`/admin/theme`)
|
|
||||||
- On-site editor panel (page editor Theme tab)
|
|
||||||
|
|
||||||
Components render settings controls that emit standard events:
|
Components render settings controls that emit standard events:
|
||||||
- `update_setting` / `theme_update_setting` (phx-click/phx-change)
|
- `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">
|
<.link href={~p"/"} class="admin-btn admin-btn-primary">
|
||||||
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
|
<.icon name="hero-arrow-top-right-on-square-mini" class="size-4" /> View your shop
|
||||||
</.link>
|
</.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
|
<.icon name="hero-paint-brush-mini" class="size-4" /> Customise theme
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
@ -273,7 +273,7 @@ defmodule BerrypodWeb.Admin.Dashboard do
|
|||||||
%{
|
%{
|
||||||
key: :theme_customised,
|
key: :theme_customised,
|
||||||
label: "Customise your theme",
|
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."
|
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
|
live "/redirects", Admin.Redirects, :index
|
||||||
end
|
end
|
||||||
|
|
||||||
# Theme editor: admin root layout but full-screen (no sidebar)
|
# Theme editor redirects to on-site editing
|
||||||
live_session :admin_theme,
|
get "/theme", RedirectController, :theme
|
||||||
on_mount: [{BerrypodWeb.UserAuth, :require_authenticated}] do
|
|
||||||
live "/theme", Admin.Theme.Index, :index
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# User account settings
|
# 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"]), "Dashboard")
|
||||||
assert has_element?(view, ~s(a[href="/admin/orders"]), "Orders")
|
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")
|
assert has_element?(view, ~s(a[href="/admin/settings"]), "Settings")
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -45,24 +45,6 @@ defmodule BerrypodWeb.Admin.LayoutTest do
|
|||||||
end
|
end
|
||||||
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
|
describe "admin bar on shop pages" do
|
||||||
setup do
|
setup do
|
||||||
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
||||||
|
|||||||
@ -1,216 +1,24 @@
|
|||||||
defmodule BerrypodWeb.Admin.ThemeTest do
|
defmodule BerrypodWeb.Admin.ThemeTest do
|
||||||
use BerrypodWeb.ConnCase, async: false
|
use BerrypodWeb.ConnCase, async: false
|
||||||
|
|
||||||
import Phoenix.LiveViewTest
|
|
||||||
import Berrypod.AccountsFixtures
|
import Berrypod.AccountsFixtures
|
||||||
|
|
||||||
alias Berrypod.Settings
|
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
%{user: user}
|
%{user: user}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Index (unauthenticated)" do
|
describe "/admin/theme redirect" do
|
||||||
test "redirects to login when not authenticated", %{conn: conn} do
|
test "redirects unauthenticated users to login", %{conn: conn} do
|
||||||
{:error, redirect} = live(conn, ~p"/admin/theme")
|
conn = get(conn, ~p"/admin/theme")
|
||||||
|
|
||||||
assert {:redirect, %{to: path}} = redirect
|
assert redirected_to(conn) == ~p"/users/log-in"
|
||||||
assert path == ~p"/users/log-in"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Index (authenticated)" do
|
test "redirects authenticated users to on-site editor", %{conn: conn, user: user} do
|
||||||
setup %{conn: conn, user: user} do
|
conn = conn |> log_in_user(user) |> get(~p"/admin/theme")
|
||||||
conn = log_in_user(conn, user)
|
|
||||||
%{conn: conn}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "renders theme editor page", %{conn: conn} do
|
assert redirected_to(conn) == "/?edit=theme"
|
||||||
{:ok, _view, html} = live(conn, ~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:"
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
defmodule BerrypodWeb.ThemeCSSConsistencyTest do
|
defmodule BerrypodWeb.ThemeCSSConsistencyTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tests that verify CSS works correctly for both the theme editor
|
Tests that verify CSS works correctly for shop pages using the .themed class.
|
||||||
preview and the shop pages using the shared .themed class.
|
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
- Both shop pages and preview use .themed class for shared styles
|
- Shop pages use .themed class for theme-aware styles
|
||||||
- Theme editor uses .preview-frame[data-*] selectors for live switching (in admin.css)
|
- 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 (shop.css)
|
- Shop pages get theme values via inline CSS from CSSGenerator
|
||||||
- Component styles use .themed for shared styling (theme-layer2-attributes.css)
|
- Component styles use .themed for shared styling (theme-layer2-attributes.css)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -34,11 +33,12 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do
|
|||||||
assert html =~ ~r/data-density="/
|
assert html =~ ~r/data-density="/
|
||||||
end
|
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)
|
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/<div[^>]*class="themed/
|
||||||
assert html =~ ~r/data-mood="/
|
assert html =~ ~r/data-mood="/
|
||||||
assert html =~ ~r/data-typography="/
|
assert html =~ ~r/data-typography="/
|
||||||
@ -46,26 +46,6 @@ defmodule BerrypodWeb.ThemeCSSConsistencyTest do
|
|||||||
assert html =~ ~r/data-density="/
|
assert html =~ ~r/data-density="/
|
||||||
end
|
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
|
test "theme settings changes are reflected on shop page", %{conn: conn, user: user} do
|
||||||
conn = log_in_user(conn, user)
|
conn = log_in_user(conn, user)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user