Some checks failed
deploy / deploy (push) Has been cancelled
Legal pages (privacy, delivery, terms) now auto-populate content from shop settings on mount, show auto-generated vs customised badges, and have a regenerate button. Theme editor gains alt text fields for logo, header, and icon images. Image picker in page builder now has an upload button and alt text warning badges. Clearing unused image references shows an orphan info flash. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
547 lines
16 KiB
Elixir
547 lines
16 KiB
Elixir
defmodule BerrypodWeb.Admin.Theme.Index do
|
|
use BerrypodWeb, :live_view
|
|
|
|
alias Berrypod.{Pages, Settings}
|
|
alias Berrypod.Media
|
|
alias Berrypod.Theme.{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(: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)
|
|
|> 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, socket}
|
|
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 | _] ->
|
|
{:noreply, assign(socket, :header_image, image)}
|
|
|
|
_ ->
|
|
{: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)
|
|
|> 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
|
|
|
|
@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)
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, _} ->
|
|
{: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)
|
|
|
|
{: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)
|
|
attrs = %{field_atom => !current_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)
|
|
|
|
{:noreply, socket}
|
|
|
|
{:error, _} ->
|
|
{:noreply, socket}
|
|
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
|
|
|
|
Settings.update_theme_settings(%{logo_image_id: nil})
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:logo_image, nil)
|
|
|> 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)
|
|
|> 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
|
|
|
|
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 :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
|
|
end
|