defmodule BerrypodWeb.Admin.Pages.Editor do use BerrypodWeb, :live_view alias Berrypod.{LegalPages, Media, Pages, Products} alias Berrypod.Pages.{BlockEditor, BlockTypes, Page} alias Berrypod.Products.ProductImage alias Berrypod.Theme.{Fonts, PreviewData} import BerrypodWeb.BlockEditorComponents @impl true def mount(%{"slug" => slug}, _session, socket) do page = Pages.get_page(slug) allowed_blocks = BlockTypes.allowed_for(slug) real_products = Products.list_visible_products(limit: 8) real_categories = Products.list_categories() preview_data = %{ products: if(real_products != [], do: real_products, else: PreviewData.products()), categories: if(real_categories != [], do: real_categories, else: PreviewData.categories()), cart_items: PreviewData.cart_items() } {:ok, socket |> assign(:page_title, page.title) |> assign(:slug, slug) |> assign(:page_data, page) |> assign(:blocks, page.blocks) |> assign(:allowed_blocks, allowed_blocks) |> assign(:history, []) |> assign(:future, []) |> assign(:dirty, false) |> assign(:show_picker, false) |> assign(:picker_filter, "") |> assign(:expanded, MapSet.new()) |> assign(:live_region_message, nil) |> assign(:show_preview, false) |> assign(:preview_data, preview_data) |> assign(:logo_image, Media.get_logo()) |> assign(:header_image, Media.get_header()) |> assign(:image_picker_block_id, nil) |> assign(:image_picker_field_key, nil) |> assign(:image_picker_images, []) |> assign(:image_picker_search, "") |> assign(:is_custom_page, !Page.system_slug?(slug)) |> assign(:is_legal_page, LegalPages.legal_slug?(slug)) |> assign_settings_form(slug) |> allow_upload(:image_picker_upload, accept: ~w(.png .jpg .jpeg .webp .svg), max_entries: 1, max_file_size: 5_000_000, auto_upload: true, progress: &handle_image_picker_upload/3 ) |> assign_content_source(slug, page.blocks)} end defp handle_image_picker_upload(:image_picker_upload, entry, socket) do if entry.done? do consume_uploaded_entries(socket, :image_picker_upload, fn %{path: path}, entry -> case Media.upload_from_entry(path, entry, "media") do {:ok, image} -> {:ok, image} {:error, _} = error -> error end end) |> case do [{:ok, image}] -> block_id = socket.assigns.image_picker_block_id field_key = socket.assigns.image_picker_field_key case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{ field_key => image.id }) do {:ok, new_blocks} -> {:noreply, socket |> apply_mutation(new_blocks, "Image uploaded and selected") |> assign(:image_picker_block_id, nil) |> assign(:image_picker_field_key, nil)} :noop -> {:noreply, socket} end _ -> {:noreply, put_flash(socket, :error, "Upload failed")} end else {:noreply, socket} end end # Determines whether legal page content is auto-generated or customised. # For non-legal pages, content_source is nil (not shown). # On mount, auto-populates empty legal pages with generated content. defp assign_content_source(socket, slug, blocks) do if LegalPages.legal_slug?(slug) do current_content = get_content_body_text(blocks) fresh_content = LegalPages.regenerate_legal_content(slug) cond do # Empty content — auto-populate on mount current_content == "" -> blocks = populate_content_body(blocks, fresh_content) socket |> assign(:blocks, blocks) |> assign(:content_source, :auto) # Content matches generated — it's auto current_content == fresh_content -> assign(socket, :content_source, :auto) # Content differs — it's been customised true -> assign(socket, :content_source, :custom) end else assign(socket, :content_source, nil) end end defp populate_content_body(blocks, content) do Enum.map(blocks, fn %{"type" => "content_body", "settings" => settings} = block -> %{block | "settings" => Map.put(settings || %{}, "content", content)} block -> block end) end defp get_content_body_text(blocks) do case Enum.find(blocks, &(&1["type"] == "content_body")) do %{"settings" => %{"content" => content}} when is_binary(content) -> content _ -> "" end end # ── Block manipulation events ──────────────────────────────────── @impl true def handle_event("move_up", %{"id" => block_id}, socket) do case BlockEditor.move_up(socket.assigns.blocks, block_id) do {:ok, new_blocks, message} -> {:noreply, apply_mutation(socket, new_blocks, message)} :noop -> {:noreply, socket} end end def handle_event("move_down", %{"id" => block_id}, socket) do case BlockEditor.move_down(socket.assigns.blocks, block_id) do {:ok, new_blocks, message} -> {:noreply, apply_mutation(socket, new_blocks, message)} :noop -> {:noreply, socket} end end def handle_event("remove_block", %{"id" => block_id}, socket) do {:ok, new_blocks, message} = BlockEditor.remove_block(socket.assigns.blocks, block_id) {:noreply, apply_mutation(socket, new_blocks, message)} end def handle_event("duplicate_block", %{"id" => block_id}, socket) do case BlockEditor.duplicate_block(socket.assigns.blocks, block_id) do {:ok, new_blocks, message} -> {:noreply, apply_mutation(socket, new_blocks, message)} :noop -> {:noreply, socket} end end def handle_event("add_block", %{"type" => type}, socket) do case BlockEditor.add_block(socket.assigns.blocks, type) do {:ok, new_blocks, message} -> {:noreply, socket |> apply_mutation(new_blocks, message) |> assign(:show_picker, false)} :noop -> {:noreply, socket} end end def handle_event("update_block_settings", params, socket) do block_id = params["block_id"] new_settings = params["block_settings"] || %{} case BlockEditor.update_settings(socket.assigns.blocks, block_id, new_settings) do {:ok, new_blocks} -> socket = apply_mutation(socket, new_blocks, "Settings updated") # Update content source badge if a legal page content block was edited socket = if socket.assigns.is_legal_page do assign_content_source(socket, socket.assigns.slug, new_blocks) else socket end {:noreply, socket} :noop -> {:noreply, socket} end end # ── Repeater events ────────────────────────────────────────────── def handle_event("repeater_add", %{"block-id" => block_id, "field" => field_key}, socket) do case BlockEditor.repeater_add(socket.assigns.blocks, block_id, field_key) do {:ok, new_blocks, message} -> {:noreply, apply_mutation(socket, new_blocks, message)} :noop -> {:noreply, socket} end end def handle_event( "repeater_remove", %{"block-id" => block_id, "field" => field_key, "index" => index_str}, socket ) do index = String.to_integer(index_str) case BlockEditor.repeater_remove(socket.assigns.blocks, block_id, field_key, index) do {:ok, new_blocks, message} -> {:noreply, apply_mutation(socket, new_blocks, message)} :noop -> {:noreply, socket} end end def handle_event( "repeater_move", %{"block-id" => block_id, "field" => field_key, "index" => index_str, "dir" => dir}, socket ) do index = String.to_integer(index_str) case BlockEditor.repeater_move(socket.assigns.blocks, block_id, field_key, index, dir) do {:ok, new_blocks, message} -> {:noreply, apply_mutation(socket, new_blocks, message)} :noop -> {:noreply, socket} end end # ── Image picker events ────────────────────────────────────────── def handle_event( "show_image_picker", %{"block-id" => block_id, "field" => field_key}, socket ) do images = Media.list_images() {:noreply, socket |> assign(:image_picker_block_id, block_id) |> assign(:image_picker_field_key, field_key) |> assign(:image_picker_images, images) |> assign(:image_picker_search, "")} end def handle_event("hide_image_picker", _params, socket) do {:noreply, socket |> assign(:image_picker_block_id, nil) |> assign(:image_picker_field_key, nil)} end def handle_event("image_picker_search", %{"value" => value}, socket) do {:noreply, assign(socket, :image_picker_search, value)} end def handle_event("pick_image", %{"image-id" => image_id}, socket) do block_id = socket.assigns.image_picker_block_id field_key = socket.assigns.image_picker_field_key case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => image_id}) do {:ok, new_blocks} -> {:noreply, socket |> apply_mutation(new_blocks, "Image selected") |> assign(:image_picker_block_id, nil) |> assign(:image_picker_field_key, nil)} :noop -> {:noreply, socket} end end def handle_event( "clear_image", %{"block-id" => block_id, "field" => field_key}, socket ) do # Grab the old image_id before clearing old_image_id = case Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) do %{"settings" => settings} -> settings[field_key] _ -> nil end case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{field_key => ""}) do {:ok, new_blocks} -> socket = apply_mutation(socket, new_blocks, "Image cleared") socket = if is_binary(old_image_id) && old_image_id != "" && Media.find_usages(old_image_id) == [] do put_flash( socket, :info, "Image removed — it's no longer used anywhere and can be deleted from the media library" ) else socket end {:noreply, socket} :noop -> {:noreply, socket} end end # ── UI events ──────────────────────────────────────────────────── def handle_event("toggle_expand", %{"id" => block_id}, socket) do expanded = socket.assigns.expanded block = Enum.find(socket.assigns.blocks, &(&1["id"] == block_id)) block_name = BlockEditor.block_display_name(block) {new_expanded, action} = if MapSet.member?(expanded, block_id) do {MapSet.delete(expanded, block_id), "collapsed"} else {MapSet.put(expanded, block_id), "expanded"} end {:noreply, socket |> assign(:expanded, new_expanded) |> assign(:live_region_message, "#{block_name} settings #{action}")} end def handle_event("show_picker", _params, socket) do {:noreply, socket |> assign(:show_picker, true) |> assign(:picker_filter, "")} end def handle_event("hide_picker", _params, socket) do {:noreply, assign(socket, :show_picker, false)} end def handle_event("filter_picker", %{"value" => value}, socket) do {:noreply, assign(socket, :picker_filter, value)} end def handle_event("toggle_preview", _params, socket) do {:noreply, assign(socket, :show_preview, !socket.assigns.show_preview)} end # ── Page actions ───────────────────────────────────────────────── def handle_event("save", _params, socket) do %{slug: slug, blocks: blocks, page_data: page_data} = socket.assigns case Pages.save_page(slug, %{title: page_data.title, blocks: blocks}) do {:ok, _page} -> {:noreply, socket |> assign(:dirty, false) |> put_flash(:info, "Page saved")} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to save page")} end end def handle_event("regenerate_legal", _params, socket) do slug = socket.assigns.slug content = LegalPages.regenerate_legal_content(slug) case Enum.find(socket.assigns.blocks, &(&1["type"] == "content_body")) do %{"id" => block_id} -> case BlockEditor.update_settings(socket.assigns.blocks, block_id, %{"content" => content}) do {:ok, new_blocks} -> {:noreply, socket |> apply_mutation(new_blocks, "Legal content regenerated") |> assign(:content_source, :auto) |> put_flash(:info, "Content regenerated from current settings")} :noop -> {:noreply, socket} end nil -> {:noreply, put_flash(socket, :error, "No content block found on this page")} end end def handle_event("reset_defaults", _params, socket) do slug = socket.assigns.slug :ok = Pages.reset_page(slug) page = Pages.get_page(slug) {:noreply, socket |> assign(:blocks, page.blocks) |> assign(:history, []) |> assign(:future, []) |> assign(:dirty, false) |> put_flash(:info, "Page reset to defaults")} end def handle_event("undo", _params, socket) do case socket.assigns.history do [prev | rest] -> future = [socket.assigns.blocks | socket.assigns.future] {:noreply, socket |> assign(:blocks, prev) |> assign(:history, rest) |> assign(:future, future) |> assign(:dirty, prev != socket.assigns.page_data.blocks) |> assign(:live_region_message, "Undone")} [] -> {:noreply, socket} end end def handle_event("redo", _params, socket) do case socket.assigns.future do [next | rest] -> history = [socket.assigns.blocks | socket.assigns.history] {:noreply, socket |> assign(:blocks, next) |> assign(:history, history) |> assign(:future, rest) |> assign(:dirty, true) |> assign(:live_region_message, "Redone")} [] -> {:noreply, socket} end end # ── Page settings (custom pages only) ───────────────────────────── def handle_event("toggle_settings", _params, socket) do {:noreply, assign(socket, :show_settings, !socket.assigns.show_settings)} end def handle_event("validate_settings", %{"page" => params}, socket) do form = socket.assigns.page_struct |> Page.custom_changeset(params) |> Map.put(:action, :validate) |> to_form() {:noreply, assign(socket, :settings_form, form)} end def handle_event("save_settings", %{"page" => params}, socket) do case Pages.update_custom_page(socket.assigns.page_struct, params) do {:ok, page} -> {:noreply, socket |> assign(:page_struct, page) |> assign(:page_data, %{socket.assigns.page_data | title: page.title}) |> assign(:page_title, page.title) |> assign(:settings_form, to_form(Page.custom_changeset(page, %{}))) |> assign(:show_settings, false) |> put_flash(:info, "Page settings saved")} {:error, changeset} -> {:noreply, assign(socket, :settings_form, to_form(changeset))} end end defp assign_settings_form(socket, slug) do if Page.system_slug?(slug) do socket |> assign(:show_settings, false) |> assign(:page_struct, nil) |> assign(:settings_form, nil) else page_struct = Pages.get_page_struct(slug) socket |> assign(:show_settings, false) |> assign(:page_struct, page_struct) |> assign(:settings_form, to_form(Page.custom_changeset(page_struct, %{}))) end end # ── Render ─────────────────────────────────────────────────────── @impl true def render(assigns) do ~H"""
<.link navigate={~p"/admin/pages"} class="text-sm font-normal text-base-content/60 hover:underline" > ← Pages <.header> {@page_data.title} <:actions> <%!-- ARIA live region for screen reader announcements --%>
{if @live_region_message, do: @live_region_message}
<%!-- Status badges --%>

Unsaved changes

Auto-generated from settings

Customised

<%!-- Inline page settings (custom pages) --%>
<.form for={@settings_form} id="inline-page-settings" phx-change="validate_settings" phx-submit="save_settings" >
<.input field={@settings_form[:title]} label="Title" /> <.input field={@settings_form[:slug]} label="URL slug" /> <.input field={@settings_form[:meta_description]} type="textarea" label="Meta description" phx-no-feedback />
<.input field={@settings_form[:published]} type="checkbox" label="Published" /> <.input field={@settings_form[:show_in_nav]} type="checkbox" label="Show in navigation" />
<.button type="submit" phx-disable-with="Saving..." class="admin-btn-sm"> Save settings
<%!-- Editor pane --%>
<%!-- Block list --%>
<.block_card :for={{block, idx} <- Enum.with_index(@blocks)} block={block} idx={idx} total={length(@blocks)} expanded={@expanded} />

No blocks on this page yet.

<%!-- Add block button --%>
<%!-- Preview pane --%>
<.preview_pane slug={@slug} blocks={@blocks} page_data={@page_data} preview_data={@preview_data} theme_settings={@theme_settings} generated_css={@generated_css} logo_image={@logo_image} header_image={@header_image} />
<%!-- Block picker modal --%> <.block_picker :if={@show_picker} allowed_blocks={@allowed_blocks} filter={@picker_filter} /> <%!-- Image picker modal --%> <.image_picker :if={@image_picker_block_id} images={@image_picker_images} search={@image_picker_search} upload={@uploads.image_picker_upload} />
""" end # ── Preview ─────────────────────────────────────────────────────── defp preview_pane(assigns) do page = %{slug: assigns.slug, title: assigns.page_data.title, blocks: assigns.blocks} preview = assigns |> assign(:page, page) |> assign(:mode, :preview) |> assign(:products, assigns.preview_data.products) |> assign(:categories, assigns.preview_data.categories) |> assign(:cart_items, PreviewData.cart_drawer_items()) |> assign(:cart_count, 2) |> assign(:cart_subtotal, "£72.00") |> assign(:cart_drawer_open, false) |> assign(:header_nav_items, BerrypodWeb.ThemeHook.default_header_nav()) |> assign(:footer_nav_items, BerrypodWeb.ThemeHook.default_footer_nav()) |> preview_page_context(assigns.slug) extra = Pages.load_block_data(page.blocks, preview) assigns = assign(assigns, :preview, assign(preview, extra)) ~H"""
""" end 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()) defp preview_page_context(assigns, "delivery"), do: assign(assigns, :content_blocks, PreviewData.delivery_content()) defp preview_page_context(assigns, "privacy"), do: assign(assigns, :content_blocks, PreviewData.privacy_content()) defp preview_page_context(assigns, "terms"), do: assign(assigns, :content_blocks, PreviewData.terms_content()) 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 (Map.get(product, :images) || []) |> Enum.sort_by(& &1.position) |> Enum.map(fn img -> ProductImage.url(img, 1200) end) |> Enum.reject(&is_nil/1) end # ── Helpers ────────────────────────────────────────────────────── defp apply_mutation(socket, new_blocks, message) do history = [socket.assigns.blocks | socket.assigns.history] |> Enum.take(50) socket |> assign(:blocks, new_blocks) |> assign(:history, history) |> assign(:future, []) |> assign(:dirty, true) |> assign(:live_region_message, message) end end