diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 38078bf..54f882b 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -2534,6 +2534,13 @@ cursor: pointer; } +/* On larger screens, hide the overlay so you can navigate while editing */ +@media (min-width: 768px) { + .editor-overlay { + display: none; + } +} + /* ── Editor panel ── */ .editor-panel { position: fixed; diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index 24f4a88..de0ace7 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -57,7 +57,8 @@ background-color: #e5e7eb; } - .product-card-image-wrap img { + .product-card-image-wrap img, + .product-card-image-wrap picture { width: 100%; height: 100%; object-fit: cover; diff --git a/lib/berrypod_web/components/shop_components.ex b/lib/berrypod_web/components/shop_components.ex index 7771862..4215125 100644 --- a/lib/berrypod_web/components/shop_components.ex +++ b/lib/berrypod_web/components/shop_components.ex @@ -9,6 +9,7 @@ defmodule BerrypodWeb.ShopComponents do - `Cart` — cart drawer, cart items, order summary - `Product` — product cards, gallery, variant selector, hero sections - `Content` — rich text, responsive images, contact form, reviews + - `ThemeEditor` — shared theme editor components for admin and on-site editing """ defmacro __using__(_opts \\ []) do @@ -18,6 +19,7 @@ defmodule BerrypodWeb.ShopComponents do import BerrypodWeb.ShopComponents.Content import BerrypodWeb.ShopComponents.Layout import BerrypodWeb.ShopComponents.Product + import BerrypodWeb.ShopComponents.ThemeEditor end end end diff --git a/lib/berrypod_web/components/shop_components/base.ex b/lib/berrypod_web/components/shop_components/base.ex index 8f776ad..f7bf5dc 100644 --- a/lib/berrypod_web/components/shop_components/base.ex +++ b/lib/berrypod_web/components/shop_components/base.ex @@ -171,7 +171,7 @@ defmodule BerrypodWeb.ShopComponents.Base do def shop_link_button(assigns) do ~H""" <.link - navigate={@href} + patch={@href} class={["themed-button", @class]} > {render_slot(@inner_block)} @@ -203,7 +203,7 @@ defmodule BerrypodWeb.ShopComponents.Base do def shop_link_outline(assigns) do ~H""" <.link - navigate={@href} + patch={@href} class={["themed-button-outline", @class]} > {render_slot(@inner_block)} diff --git a/lib/berrypod_web/components/shop_components/cart.ex b/lib/berrypod_web/components/shop_components/cart.ex index 7677560..a5b4146 100644 --- a/lib/berrypod_web/components/shop_components/cart.ex +++ b/lib/berrypod_web/components/shop_components/cart.ex @@ -178,7 +178,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do > <%= if @mode != :preview do %> <.link - navigate={"/products/#{@item.product_id}"} + patch={"/products/#{@item.product_id}"} class={["cart-item-image", !@item.image && "cart-item-image--empty"]} data-size={if @size == :compact, do: "compact"} style={if @item.image, do: "background-image: url('#{@item.image}');"} @@ -197,7 +197,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do

<%= if @mode != :preview do %> <.link - navigate={"/products/#{@item.product_id}"} + patch={"/products/#{@item.product_id}"} class="cart-item-name-link" > {@item.name} @@ -296,7 +296,7 @@ defmodule BerrypodWeb.ShopComponents.Cart do <% else %> <.link - navigate="/collections/all" + patch="/collections/all" class="cart-continue-link" > Continue shopping diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index 9dcd9ae..14ce602 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -266,7 +266,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <% else %> <.link - navigate={@href} + patch={@href} class="mobile-nav-link" aria-current={if @is_current, do: "page", else: nil} > @@ -484,7 +484,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do aria-selected="false" > <.link - navigate={"/products/#{item.product.slug || item.product.id}"} + patch={"/products/#{item.product.slug || item.product.id}"} class="search-result" phx-click={Phoenix.LiveView.JS.dispatch("close-search", to: "#search-modal")} > @@ -588,7 +588,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <% else %> <.link - navigate={item["href"]} + patch={item["href"]} class="mobile-nav-link" aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"} phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")} @@ -615,7 +615,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <% else %> <.link - navigate={"/collections/#{category.slug}"} + patch={"/collections/#{category.slug}"} class="mobile-nav-link" phx-click={ Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer") @@ -700,7 +700,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <% else %>
  • <.link - navigate="/collections/all" + patch="/collections/all" class="footer-link" > All products @@ -709,7 +709,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do <%= for category <- @categories do %>
  • <.link - navigate={"/collections/#{category.slug}"} + patch={"/collections/#{category.slug}"} class="footer-link" > {category.name} @@ -735,7 +735,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do {item["label"]} <% else %> - <.link navigate={item["href"]} class="footer-link"> + <.link patch={item["href"]} class="footer-link"> {item["label"]} <% end %> @@ -929,7 +929,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do /> <% else %> - <.link navigate="/" class="shop-logo-link"> + <.link patch="/" class="shop-logo-link"> <.logo_inner theme_settings={@theme_settings} site_name={@site_name} @@ -1015,7 +1015,7 @@ defmodule BerrypodWeb.ShopComponents.Layout do {@label} <% else %> - <.link navigate={@href} class="nav-link"> + <.link patch={@href} class="nav-link"> {@label} <% end %> diff --git a/lib/berrypod_web/components/shop_components/product.ex b/lib/berrypod_web/components/shop_components/product.ex index cd51ffd..f2debc8 100644 --- a/lib/berrypod_web/components/shop_components/product.ex +++ b/lib/berrypod_web/components/shop_components/product.ex @@ -157,7 +157,7 @@ defmodule BerrypodWeb.ShopComponents.Product do

    <% else %> <.link - navigate={"/collections/#{Slug.slugify(@product.category)}"} + patch={"/collections/#{Slug.slugify(@product.category)}"} class="product-card-category" > {@product.category} @@ -177,7 +177,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <% else %> <.link - navigate={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"} + patch={"/products/#{Map.get(@product, :slug) || Map.get(@product, :id)}"} class="stretched-link" > {@product.title} @@ -205,7 +205,7 @@ defmodule BerrypodWeb.ShopComponents.Product do defp product_card_image_wrap(assigns) do ~H""" <%= if @href do %> - <.link navigate={@href} class="product-card-image-wrap" tabindex="-1" aria-hidden="true"> + <.link patch={@href} class="product-card-image-wrap" tabindex="-1" aria-hidden="true"> {render_slot(@inner_block)} <% else %> @@ -571,7 +571,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <% else %> <.link - navigate={@href || "/"} + patch={@href || "/"} class={@cta_class} > {@text} @@ -627,7 +627,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <% else %> <.link - navigate={"/collections/#{category.slug}"} + patch={"/collections/#{category.slug}"} class="category-card" >
    <% else %> <.link - navigate={@cta_href} + patch={@cta_href} class="outline-button" > {@cta_text} @@ -826,7 +826,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <% else %> <.link - navigate={@link_href || "/"} + patch={@link_href || "/"} class="accent-link" > {@link_text} @@ -960,7 +960,7 @@ defmodule BerrypodWeb.ShopComponents.Product do {item.label} <% else %> - <.link navigate={item.href || "/"}>{item.label} + <.link patch={item.href || "/"}>{item.label} <% end %>
  • <% end %> @@ -1749,7 +1749,7 @@ defmodule BerrypodWeb.ShopComponents.Product do
    <.link :if={@page.page > 1} - navigate={pagination_url(@base_path, @page.page - 1, @params)} + patch={pagination_url(@base_path, @page.page - 1, @params)} class="shop-pagination-btn" aria-label="Previous page" > @@ -1762,7 +1762,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <% n -> %> <.link - navigate={pagination_url(@base_path, n, @params)} + patch={pagination_url(@base_path, n, @params)} aria-label={"Page #{n}"} aria-current={n == @page.page && "page"} class={["shop-pagination-btn", n == @page.page && "shop-pagination-btn-active"]} @@ -1774,7 +1774,7 @@ defmodule BerrypodWeb.ShopComponents.Product do <.link :if={@page.page < @page.total_pages} - navigate={pagination_url(@base_path, @page.page + 1, @params)} + patch={pagination_url(@base_path, @page.page + 1, @params)} class="shop-pagination-btn" aria-label="Next page" > diff --git a/lib/berrypod_web/components/shop_components/theme_editor.ex b/lib/berrypod_web/components/shop_components/theme_editor.ex new file mode 100644 index 0000000..be39a8a --- /dev/null +++ b/lib/berrypod_web/components/shop_components/theme_editor.ex @@ -0,0 +1,731 @@ +defmodule BerrypodWeb.ShopComponents.ThemeEditor do + @moduledoc """ + Shared theme editor components used in both: + - Admin theme page (`/admin/theme`) + - On-site editor panel (page editor Theme tab) + + Components render settings controls that emit standard events: + - `update_setting` / `theme_update_setting` (phx-click/phx-change) + - `toggle_setting` / `theme_toggle_setting` (phx-click) + - `apply_preset` / `theme_apply_preset` (phx-click) + - `update_color` / `theme_update_color` (phx-change) + + The event prefix is controlled by `@event_prefix`: + - `""` (default) for admin context + - `"theme_"` for on-site editor context + """ + + use Phoenix.Component + + # ── Quick Settings ───────────────────────────────────────────────── + # These are the core settings shown in both compact and full modes. + + @doc """ + Renders the shop name input field. + """ + attr :site_name, :string, required: true + attr :event_prefix, :string, default: "" + + def shop_name_input(assigns) do + ~H""" +
    + +
    "update_setting"} phx-value-field="site_name"> + +
    +
    + """ + end + + @doc """ + Renders the preset grid for quick theme switching. + """ + attr :presets, :list, required: true + attr :active_preset, :atom, default: nil + attr :event_prefix, :string, default: "" + attr :label, :string, default: "Preset" + + def preset_grid(assigns) do + ~H""" +
    + +
    + <%= for {preset_name, description} <- @presets do %> + + <% end %> +
    +
    + """ + end + + @doc """ + Renders the colour mood chip selector. + """ + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + def mood_chips(assigns) do + ~H""" +
    + +
    + <%= for mood <- ["warm", "neutral", "cool", "dark"] do %> + + <% end %> +
    +
    + """ + end + + @doc """ + Renders the font style chip selector. + """ + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + def typography_chips(assigns) do + ~H""" +
    + +
    + <%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %> + + <% end %> +
    +
    + """ + end + + @doc """ + Renders the corner style chip selector. + """ + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + def shape_chips(assigns) do + ~H""" +
    + +
    + <%= for shape <- ["sharp", "soft", "round", "pill"] do %> + + <% end %> +
    +
    + """ + end + + # ── Accent Colors ────────────────────────────────────────────────── + + @doc """ + Renders an accent colour picker with ColorSync hook. + """ + attr :field, :string, required: true + attr :label, :string, required: true + attr :value, :string, required: true + attr :event_prefix, :string, default: "" + + def color_picker(assigns) do + ~H""" +
    + +
    "update_color"} + phx-value-field={@field} + phx-hook="ColorSync" + > +
    + + {@value} +
    +
    +
    + """ + end + + # ── Compact Mode ─────────────────────────────────────────────────── + # Quick settings panel for on-site editor. + + @doc """ + Renders the on-site theme editor panel. + + Shows all theme settings including presets, colours, and the full + customise accordion with advanced options. + """ + attr :theme_settings, :map, required: true + attr :active_preset, :atom, default: nil + attr :presets, :list, default: [] + attr :site_name, :string, default: "" + attr :customise_open, :boolean, default: false + attr :event_prefix, :string, default: "theme_" + + def compact_editor(assigns) do + ~H""" +
    + <%= if @theme_settings do %> + <.shop_name_input site_name={@site_name} event_prefix={@event_prefix} /> + <.preset_grid presets={@presets} active_preset={@active_preset} event_prefix={@event_prefix} /> + + <.color_picker + field="accent_color" + label="Accent colour" + value={@theme_settings.accent_color} + event_prefix={@event_prefix} + /> + <.color_picker + field="secondary_accent_color" + label="Hover colour" + value={@theme_settings.secondary_accent_color} + event_prefix={@event_prefix} + /> + <.color_picker + field="sale_color" + label="Sale colour" + value={@theme_settings.sale_color} + event_prefix={@event_prefix} + /> + + <.customise_accordion + theme_settings={@theme_settings} + customise_open={@customise_open} + event_prefix={@event_prefix} + /> + +
    +

    + For logo and header image uploads, visit the full theme editor. +

    +
    + <% else %> +

    Loading theme settings...

    + <% end %> +
    + """ + end + + # ── Full Customise Accordion ─────────────────────────────────────── + # Advanced settings groups for admin theme page. + + @doc """ + Renders the customise accordion with all advanced settings groups. + """ + attr :theme_settings, :map, required: true + attr :customise_open, :boolean, default: false + attr :event_prefix, :string, default: "" + + def customise_accordion(assigns) do + ~H""" +
    + "toggle_customise"}> + Customise + + + + + +
    + <.typography_group theme_settings={@theme_settings} event_prefix={@event_prefix} /> + <.colours_group theme_settings={@theme_settings} event_prefix={@event_prefix} /> + <.layout_group theme_settings={@theme_settings} event_prefix={@event_prefix} /> + <.shape_group theme_settings={@theme_settings} event_prefix={@event_prefix} /> + <.products_group theme_settings={@theme_settings} event_prefix={@event_prefix} /> + <.product_page_group theme_settings={@theme_settings} event_prefix={@event_prefix} /> +
    +
    + """ + end + + # ── Setting Groups ───────────────────────────────────────────────── + + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + defp typography_group(assigns) do + ~H""" +
    +
    + + + + + + Typography +
    + +
    + +
    + <%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for {value, label} <- [{"small", "Small"}, {"medium", "Medium"}, {"large", "Large"}] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for {value, label} <- [{"regular", "Regular"}, {"medium", "Medium"}, {"bold", "Bold"}] do %> + + <% end %> +
    +
    +
    + """ + end + + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + defp colours_group(assigns) do + ~H""" +
    +
    + + + + + Colours +
    + +
    + +
    + <%= for mood <- ["warm", "neutral", "cool", "dark"] do %> + + <% end %> +
    +
    +
    + """ + end + + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + defp layout_group(assigns) do + ~H""" +
    +
    + + + + + + Layout +
    + +
    + +
    + <%= for cols <- ["2", "3", "4"] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for density <- ["spacious", "balanced", "compact"] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for layout <- ["standard", "centered", "left"] do %> + + <% end %> +
    +
    + + <.toggle_field + field="announcement_bar" + label="Announcement bar" + checked={@theme_settings.announcement_bar} + event_prefix={@event_prefix} + /> + + <.toggle_field + field="sticky_header" + label="Sticky header" + checked={@theme_settings.sticky_header} + event_prefix={@event_prefix} + /> +
    + """ + end + + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + defp shape_group(assigns) do + ~H""" +
    +
    + + + + Shape +
    + +
    + +
    + <%= for shape <- ["sharp", "soft", "round", "pill"] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for {value, label} <- [{"none", "None"}, {"sm", "Subtle"}, {"md", "Medium"}, {"lg", "Strong"}] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for {value, label} <- [{"filled", "Filled"}, {"outline", "Outline"}, {"soft", "Soft"}] do %> + + <% end %> +
    +
    +
    + """ + end + + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + defp products_group(assigns) do + ~H""" +
    +
    + + + + + + + Products +
    + +
    + +
    + <%= for width <- ["contained", "wide", "full"] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for {value, label} <- [{"square", "Square"}, {"portrait", "Portrait"}, {"landscape", "Landscape"}] do %> + + <% end %> +
    +
    + +
    + +
    + <%= for {value, label} <- [{"left", "Left"}, {"center", "Centre"}] do %> + + <% end %> +
    +
    + + <.toggle_field + field="hover_image" + label="Second image on hover" + checked={@theme_settings.hover_image} + event_prefix={@event_prefix} + /> + + <.toggle_field + field="show_prices" + label="Show prices" + checked={@theme_settings.show_prices} + event_prefix={@event_prefix} + /> +
    + """ + end + + attr :theme_settings, :map, required: true + attr :event_prefix, :string, default: "" + + defp product_page_group(assigns) do + ~H""" +
    +
    + + + + + Product page +
    + + <.toggle_field + field="pdp_trust_badges" + label="Trust badges" + checked={@theme_settings.pdp_trust_badges} + event_prefix={@event_prefix} + /> + + <.toggle_field + field="pdp_reviews" + label="Reviews section" + checked={@theme_settings.pdp_reviews} + event_prefix={@event_prefix} + /> + + <.toggle_field + field="pdp_related_products" + label="Related products" + checked={@theme_settings.pdp_related_products} + event_prefix={@event_prefix} + /> +
    + """ + end + + # ── Helper Components ────────────────────────────────────────────── + + attr :field, :string, required: true + attr :label, :string, required: true + attr :checked, :boolean, required: true + attr :event_prefix, :string, default: "" + + defp toggle_field(assigns) do + ~H""" +
    + +
    + """ + end +end diff --git a/lib/berrypod_web/live/admin/theme/index.html.heex b/lib/berrypod_web/live/admin/theme/index.html.heex index 170cd07..3900a38 100644 --- a/lib/berrypod_web/live/admin/theme/index.html.heex +++ b/lib/berrypod_web/live/admin/theme/index.html.heex @@ -522,561 +522,39 @@ <% end %> -
    - -
    - <%= for {preset_name, description} <- @presets_with_descriptions do %> - - <% end %> -
    -
    + <.preset_grid + presets={@presets_with_descriptions} + active_preset={@active_preset} + event_prefix="" + label="Start with a preset" + /> -
    - -
    -
    - - {@theme_settings.accent_color} -
    -
    -
    - -
    - -
    -
    - - {@theme_settings.secondary_accent_color} -
    -
    -
    - -
    - -
    -
    - - {@theme_settings.sale_color} -
    -
    -
    + <.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 - - - - - -
    - -
    -
    - - - - - - Typography -
    - -
    - -
    - <%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for {value, label} <- [{"small", "Small"}, {"medium", "Medium"}, {"large", "Large"}] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for {value, label} <- [{"regular", "Regular"}, {"medium", "Medium"}, {"bold", "Bold"}] do %> - - <% end %> -
    -
    -
    - - -
    -
    - - - - - Colours -
    - -
    - -
    - <%= for mood <- ["warm", "neutral", "cool", "dark"] do %> - - <% end %> -
    -
    -
    - - -
    -
    - - - - - - Layout -
    - -
    - -
    - <%= for cols <- ["2", "3", "4"] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for density <- ["spacious", "balanced", "compact"] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for layout <- ["standard", "centered", "left"] do %> - - <% end %> -
    -
    - -
    - -
    - -
    - -
    -
    - - -
    -
    - - - - Shape -
    - -
    - -
    - <%= for shape <- ["sharp", "soft", "round", "pill"] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for {value, label} <- [{"none", "None"}, {"sm", "Subtle"}, {"md", "Medium"}, {"lg", "Strong"}] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for {value, label} <- [{"filled", "Filled"}, {"outline", "Outline"}, {"soft", "Soft"}] do %> - - <% end %> -
    -
    -
    - - -
    -
    - - - - - - - Products -
    - -
    - -
    - <%= for width <- ["contained", "wide", "full"] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for {value, label} <- [{"square", "Square"}, {"portrait", "Portrait"}, {"landscape", "Landscape"}] do %> - - <% end %> -
    -
    - -
    - -
    - <%= for {value, label} <- [{"left", "Left"}, {"center", "Centre"}] do %> - - <% end %> -
    -
    - -
    - -
    - -
    - -
    -
    - - -
    -
    - - - - - Product page -
    - -
    - -
    - -
    - -
    - -
    - -
    -
    -
    -
    + <.customise_accordion + theme_settings={@theme_settings} + customise_open={@customise_open} + event_prefix="" + /> <% end %>
    diff --git a/lib/berrypod_web/live/shop/cart.ex b/lib/berrypod_web/live/shop/cart.ex deleted file mode 100644 index f0a69b6..0000000 --- a/lib/berrypod_web/live/shop/cart.ex +++ /dev/null @@ -1,20 +0,0 @@ -defmodule BerrypodWeb.Shop.Cart do - use BerrypodWeb, :live_view - - alias Berrypod.{Cart, Pages} - - @impl true - def mount(_params, _session, socket) do - page = Pages.get_page("cart") - {:ok, socket |> assign(:page_title, "Cart") |> assign(:page, page)} - end - - @impl true - def render(assigns) do - assigns = assign(assigns, :cart_page_subtotal, Cart.calculate_subtotal(assigns.cart_items)) - - ~H""" - - """ - end -end diff --git a/lib/berrypod_web/live/shop/checkout_success.ex b/lib/berrypod_web/live/shop/checkout_success.ex deleted file mode 100644 index fe9aeb1..0000000 --- a/lib/berrypod_web/live/shop/checkout_success.ex +++ /dev/null @@ -1,58 +0,0 @@ -defmodule BerrypodWeb.Shop.CheckoutSuccess do - use BerrypodWeb, :live_view - - alias Berrypod.{Analytics, Orders, Pages} - - @impl true - def mount(%{"session_id" => session_id}, _session, socket) do - order = Orders.get_order_by_stripe_session(session_id) - - # Subscribe to order status updates (webhook may arrive after redirect) - if order && connected?(socket) do - Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status") - end - - # Track purchase event - if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do - attrs = - BerrypodWeb.AnalyticsHook.attrs(socket) - |> Map.merge(%{pathname: "/checkout/success", revenue: order.total}) - - Analytics.track_event("purchase", attrs) - end - - # Clear the cart after successful checkout - socket = - if order && connected?(socket) do - BerrypodWeb.CartHook.broadcast_and_update(socket, []) - else - socket - end - - page = Pages.get_page("checkout_success") - - socket = - socket - |> assign(:page_title, "Order confirmed") - |> assign(:order, order) - |> assign(:page, page) - - {:ok, socket} - end - - def mount(_params, _session, socket) do - {:ok, redirect(socket, to: ~p"/")} - end - - @impl true - def handle_info({:order_paid, order}, socket) do - {:noreply, assign(socket, :order, order)} - end - - @impl true - def render(assigns) do - ~H""" - - """ - end -end diff --git a/lib/berrypod_web/live/shop/coming_soon.ex b/lib/berrypod_web/live/shop/coming_soon.ex index 0a07301..fd91dbe 100644 --- a/lib/berrypod_web/live/shop/coming_soon.ex +++ b/lib/berrypod_web/live/shop/coming_soon.ex @@ -6,6 +6,11 @@ defmodule BerrypodWeb.Shop.ComingSoon do {:ok, assign(socket, :page_title, "Coming soon")} end + @impl true + def handle_params(_params, _uri, socket) do + {:noreply, socket} + end + @impl true def render(assigns) do ~H""" diff --git a/lib/berrypod_web/live/shop/page.ex b/lib/berrypod_web/live/shop/page.ex new file mode 100644 index 0000000..1562e61 --- /dev/null +++ b/lib/berrypod_web/live/shop/page.ex @@ -0,0 +1,154 @@ +defmodule BerrypodWeb.Shop.Page do + @moduledoc """ + Unified shop LiveView that handles all shop pages. + + Using a single LiveView enables `patch` navigation between pages, + preserving socket state (including editor state) across transitions. + """ + + use BerrypodWeb, :live_view + + alias BerrypodWeb.Shop.Pages + + # Map live_action atoms to page handler modules + @page_modules %{ + home: Pages.Home, + product: Pages.Product, + collection: Pages.Collection, + cart: Pages.Cart, + contact: Pages.Contact, + search: Pages.Search, + orders: Pages.Orders, + order_detail: Pages.OrderDetail, + checkout_success: Pages.CheckoutSuccess, + custom_page: Pages.CustomPage, + # Content pages all use the same module + about: Pages.Content, + delivery: Pages.Content, + privacy: Pages.Content, + terms: Pages.Content + } + + # Pages that need session data passed to init + @session_pages [:orders, :order_detail] + + @impl true + def mount(_params, session, socket) do + # Store session for pages that need it (orders, order_detail) + {:ok, assign(socket, :_session, session)} + end + + @impl true + def handle_params(params, uri, socket) do + action = socket.assigns.live_action + prev_action = socket.assigns[:_current_page_action] + module = @page_modules[action] + + # Clean up previous page if needed (e.g., unsubscribe from PubSub) + socket = maybe_cleanup_previous_page(socket, prev_action) + + socket = + if action != prev_action do + # Page type changed - call init + socket = assign(socket, :_current_page_action, action) + + result = + if action in @session_pages do + module.init(socket, params, uri, socket.assigns._session) + else + module.init(socket, params, uri) + end + + case result do + {:noreply, socket} -> socket + {:redirect, socket} -> socket + end + else + socket + end + + # After page init, sync editor state if editing and page changed + socket = maybe_sync_editing_blocks(socket) + + # Always call handle_params for URL changes + case module.handle_params(params, uri, socket) do + {:noreply, socket} -> {:noreply, socket} + end + end + + # If editing and we navigated to a different page, reload editing_blocks + defp maybe_sync_editing_blocks(socket) do + page = socket.assigns[:page] + editing = socket.assigns[:editing] + editor_page_slug = socket.assigns[:editor_page_slug] + + if editing && page && page.slug != editor_page_slug do + # Page changed while editing - reload editing state for the new page + allowed = Berrypod.Pages.BlockTypes.allowed_for(page.slug) + at_defaults = Berrypod.Pages.Defaults.matches_defaults?(page.slug, page.blocks) + + socket + |> assign(:editing_blocks, page.blocks) + |> assign(:editor_page_slug, page.slug) + |> assign(:editor_dirty, false) + |> assign(:editor_at_defaults, at_defaults) + |> assign(:editor_history, []) + |> assign(:editor_future, []) + |> assign(:editor_expanded, MapSet.new()) + |> assign(:editor_allowed_blocks, allowed) + else + socket + end + end + + @impl true + def handle_event(event, params, socket) do + module = @page_modules[socket.assigns.live_action] + + case module.handle_event(event, params, socket) do + :cont -> + # Event not handled by page module, let hooks handle it + {:noreply, socket} + + {:noreply, socket} -> + {:noreply, socket} + end + end + + @impl true + def handle_info(msg, socket) do + module = @page_modules[socket.assigns.live_action] + + # Check if the module defines handle_info + if function_exported?(module, :handle_info, 2) do + case module.handle_info(msg, socket) do + :cont -> {:noreply, socket} + {:noreply, socket} -> {:noreply, socket} + end + else + {:noreply, socket} + end + end + + @impl true + def render(assigns) do + # Cart page needs extra assigns computed at render time + assigns = + if assigns.live_action == :cart do + Pages.Cart.compute_assigns(assigns) + else + assigns + end + + ~H""" + + """ + end + + # Clean up previous page state when transitioning + defp maybe_cleanup_previous_page(socket, :checkout_success) do + Pages.CheckoutSuccess.cleanup(socket) + end + + defp maybe_cleanup_previous_page(socket, _), do: socket +end diff --git a/lib/berrypod_web/live/shop/pages/cart.ex b/lib/berrypod_web/live/shop/pages/cart.ex new file mode 100644 index 0000000..b006c9d --- /dev/null +++ b/lib/berrypod_web/live/shop/pages/cart.ex @@ -0,0 +1,31 @@ +defmodule BerrypodWeb.Shop.Pages.Cart do + @moduledoc """ + Cart page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 3] + + alias Berrypod.Pages + + def init(socket, _params, _uri) do + page = Pages.get_page("cart") + + socket = + socket + |> assign(:page_title, "Cart") + |> assign(:page, page) + + {:noreply, socket} + end + + def handle_params(_params, _uri, socket) do + {:noreply, socket} + end + + def handle_event(_event, _params, _socket), do: :cont + + # Called from render to compute the subtotal + def compute_assigns(assigns) do + Map.put(assigns, :cart_page_subtotal, Berrypod.Cart.calculate_subtotal(assigns.cart_items)) + end +end diff --git a/lib/berrypod_web/live/shop/pages/checkout_success.ex b/lib/berrypod_web/live/shop/pages/checkout_success.ex new file mode 100644 index 0000000..80322a3 --- /dev/null +++ b/lib/berrypod_web/live/shop/pages/checkout_success.ex @@ -0,0 +1,82 @@ +defmodule BerrypodWeb.Shop.Pages.CheckoutSuccess do + @moduledoc """ + Checkout success page handler for the unified Shop.Page LiveView. + Handles PubSub subscription for order status updates. + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [connected?: 1, redirect: 2] + + alias Berrypod.{Analytics, Orders, Pages} + + def init(socket, %{"session_id" => session_id}, _uri) do + order = Orders.get_order_by_stripe_session(session_id) + + # Subscribe to order status updates (webhook may arrive after redirect) + if order && connected?(socket) do + Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status") + end + + # Track purchase event + if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do + attrs = + BerrypodWeb.AnalyticsHook.attrs(socket) + |> Map.merge(%{pathname: "/checkout/success", revenue: order.total}) + + Analytics.track_event("purchase", attrs) + end + + # Clear the cart after successful checkout + socket = + if order && connected?(socket) do + BerrypodWeb.CartHook.broadcast_and_update(socket, []) + else + socket + end + + # Track subscription for cleanup when leaving this page + socket = + if order do + assign(socket, :checkout_order_subscription, "order:#{order.id}:status") + else + socket + end + + page = Pages.get_page("checkout_success") + + socket = + socket + |> assign(:page_title, "Order confirmed") + |> assign(:order, order) + |> assign(:page, page) + + {:noreply, socket} + end + + def init(socket, _params, _uri) do + {:redirect, redirect(socket, to: "/")} + end + + def handle_params(_params, _uri, socket) do + {:noreply, socket} + end + + def handle_event(_event, _params, _socket), do: :cont + + def handle_info({:order_paid, order}, socket) do + {:noreply, assign(socket, :order, order)} + end + + def handle_info(_msg, _socket), do: :cont + + # Called when leaving this page to clean up subscription + def cleanup(socket) do + if topic = socket.assigns[:checkout_order_subscription] do + Phoenix.PubSub.unsubscribe(Berrypod.PubSub, topic) + end + + socket + |> assign(:checkout_order_subscription, nil) + |> assign(:order, nil) + end +end diff --git a/lib/berrypod_web/live/shop/collection.ex b/lib/berrypod_web/live/shop/pages/collection.ex similarity index 63% rename from lib/berrypod_web/live/shop/collection.ex rename to lib/berrypod_web/live/shop/pages/collection.ex index 1b60559..2f287c2 100644 --- a/lib/berrypod_web/live/shop/collection.ex +++ b/lib/berrypod_web/live/shop/pages/collection.ex @@ -1,5 +1,10 @@ -defmodule BerrypodWeb.Shop.Collection do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.Collection do + @moduledoc """ + Collection page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [push_patch: 2, push_navigate: 2, put_flash: 3] alias Berrypod.{Pages, Pagination, Products} @@ -12,8 +17,7 @@ defmodule BerrypodWeb.Shop.Collection do {"name_desc", "Name: Z-A"} ] - @impl true - def mount(_params, _session, socket) do + def init(socket, _params, _uri) do page = Pages.get_page("collection") socket = @@ -22,36 +26,52 @@ defmodule BerrypodWeb.Shop.Collection do |> assign(:sort_options, @sort_options) |> assign(:current_sort, "featured") - {:ok, socket} + {:noreply, socket} end - @impl true def handle_params(%{"slug" => slug} = params, _uri, socket) do sort = params["sort"] || "featured" page_num = Pagination.parse_page(params) case load_collection(slug, sort, page_num) do {:ok, title, category, pagination} -> - {:noreply, - socket - |> assign(:page_title, title) - |> assign(:page_description, collection_description(title)) - |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}") - |> assign(:collection_title, title) - |> assign(:collection_slug, slug) - |> assign(:current_category, category) - |> assign(:current_sort, sort) - |> assign(:pagination, pagination) - |> assign(:products, pagination.items)} + socket = + socket + |> assign(:page_title, title) + |> assign(:page_description, collection_description(title)) + |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}") + |> assign(:collection_title, title) + |> assign(:collection_slug, slug) + |> assign(:current_category, category) + |> assign(:current_sort, sort) + |> assign(:pagination, pagination) + |> assign(:products, pagination.items) + + {:noreply, socket} :not_found -> - {:noreply, - socket - |> put_flash(:error, "Collection not found") - |> push_navigate(to: ~p"/collections/all")} + socket = + socket + |> put_flash(:error, "Collection not found") + |> push_navigate(to: "/collections/all") + + {:noreply, socket} end end + def handle_event("sort_changed", %{"sort" => sort}, socket) do + slug = + case socket.assigns.current_category do + nil -> "all" + :sale -> "sale" + category -> category.slug + end + + {:noreply, push_patch(socket, to: "/collections/#{slug}?sort=#{sort}")} + end + + def handle_event(_event, _params, _socket), do: :cont + defp load_collection("all", sort, page) do pagination = Products.list_visible_products_paginated(sort: sort, page: page) {:ok, "All Products", nil, pagination} @@ -79,26 +99,7 @@ defmodule BerrypodWeb.Shop.Collection do end end - @impl true - def handle_event("sort_changed", %{"sort" => sort}, socket) do - slug = - case socket.assigns.current_category do - nil -> "all" - :sale -> "sale" - category -> category.slug - end - - {:noreply, push_patch(socket, to: ~p"/collections/#{slug}?sort=#{sort}")} - end - defp collection_description("All Products"), do: "Browse our full range of products." defp collection_description("Sale"), do: "Browse our current sale items." defp collection_description(title), do: "Browse our #{String.downcase(title)} collection." - - @impl true - def render(assigns) do - ~H""" - - """ - end end diff --git a/lib/berrypod_web/live/shop/contact.ex b/lib/berrypod_web/live/shop/pages/contact.ex similarity index 58% rename from lib/berrypod_web/live/shop/contact.ex rename to lib/berrypod_web/live/shop/pages/contact.ex index a3be4e2..0d7a380 100644 --- a/lib/berrypod_web/live/shop/contact.ex +++ b/lib/berrypod_web/live/shop/pages/contact.ex @@ -1,28 +1,37 @@ -defmodule BerrypodWeb.Shop.Contact do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.Contact do + @moduledoc """ + Contact page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [push_navigate: 2, put_flash: 3] alias Berrypod.{ContactNotifier, Orders} alias Berrypod.Orders.OrderNotifier alias Berrypod.Pages alias BerrypodWeb.OrderLookupController - @impl true - def mount(_params, _session, socket) do + def init(socket, _params, _uri) do page = Pages.get_page("contact") - {:ok, - socket - |> assign(:page_title, "Contact") - |> assign( - :page_description, - "Get in touch with us for any questions or help with your order." - ) - |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact") - |> assign(:tracking_state, :idle) - |> assign(:page, page)} + socket = + socket + |> assign(:page_title, "Contact") + |> assign( + :page_description, + "Get in touch with us for any questions or help with your order." + ) + |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/contact") + |> assign(:tracking_state, :idle) + |> assign(:page, page) + + {:noreply, socket} + end + + def handle_params(_params, _uri, socket) do + {:noreply, socket} end - @impl true def handle_event("lookup_orders", %{"email" => email}, socket) do orders = Orders.list_orders_by_email(email) @@ -31,7 +40,7 @@ defmodule BerrypodWeb.Shop.Contact do :not_found else token = OrderLookupController.generate_token(email) - link = BerrypodWeb.Endpoint.url() <> ~p"/orders/verify/#{token}" + link = BerrypodWeb.Endpoint.url() <> "/orders/verify/#{token}" OrderNotifier.deliver_order_lookup(email, link) :sent end @@ -39,14 +48,13 @@ defmodule BerrypodWeb.Shop.Contact do {:noreply, assign(socket, :tracking_state, state)} end - @impl true def handle_event("send_contact", params, socket) do case ContactNotifier.deliver_contact_message(params) do {:ok, _} -> {:noreply, socket |> put_flash(:info, "Message sent! We'll get back to you soon.") - |> push_navigate(to: ~p"/contact")} + |> push_navigate(to: "/contact")} {:error, :invalid_params} -> {:noreply, put_flash(socket, :error, "Please fill in all required fields.")} @@ -56,15 +64,9 @@ defmodule BerrypodWeb.Shop.Contact do end end - @impl true def handle_event("reset_tracking", _params, socket) do {:noreply, assign(socket, :tracking_state, :idle)} end - @impl true - def render(assigns) do - ~H""" - - """ - end + def handle_event(_event, _params, _socket), do: :cont end diff --git a/lib/berrypod_web/live/shop/content.ex b/lib/berrypod_web/live/shop/pages/content.ex similarity index 69% rename from lib/berrypod_web/live/shop/content.ex rename to lib/berrypod_web/live/shop/pages/content.ex index e4fa86c..7978453 100644 --- a/lib/berrypod_web/live/shop/content.ex +++ b/lib/berrypod_web/live/shop/pages/content.ex @@ -1,20 +1,25 @@ -defmodule BerrypodWeb.Shop.Content do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.Content do + @moduledoc """ + Content page handler for the unified Shop.Page LiveView. + Handles about, delivery, privacy, and terms pages. + """ + + import Phoenix.Component, only: [assign: 2, assign: 3] alias Berrypod.LegalPages alias Berrypod.Pages alias Berrypod.Theme.PreviewData - @impl true - def mount(_params, _session, socket) do - {:ok, socket} + def init(socket, _params, _uri) do + # Content pages load in handle_params based on live_action + {:noreply, socket} end - @impl true def handle_params(_params, _uri, socket) do - slug = to_string(socket.assigns.live_action) + action = socket.assigns.live_action + slug = to_string(action) page = Pages.get_page(slug) - {seo, content_blocks} = page_config(socket.assigns.live_action) + {seo, content_blocks} = page_config(action) socket = socket @@ -25,19 +30,14 @@ defmodule BerrypodWeb.Shop.Content do {:noreply, socket} end - @impl true - def render(assigns) do - ~H""" - - """ - end + def handle_event(_event, _params, _socket), do: :cont # Returns {seo_assigns, content_blocks} for each content page defp page_config(:about) do { %{ page_title: "About", - page_description: "Your story goes here \u2013 this is sample content for the demo shop", + page_description: "Your story goes here – this is sample content for the demo shop", og_url: BerrypodWeb.Endpoint.url() <> "/about" }, PreviewData.about_content() diff --git a/lib/berrypod_web/live/shop/custom_page.ex b/lib/berrypod_web/live/shop/pages/custom_page.ex similarity index 78% rename from lib/berrypod_web/live/shop/custom_page.ex rename to lib/berrypod_web/live/shop/pages/custom_page.ex index d624fa5..9dc40c0 100644 --- a/lib/berrypod_web/live/shop/custom_page.ex +++ b/lib/berrypod_web/live/shop/pages/custom_page.ex @@ -1,14 +1,17 @@ -defmodule BerrypodWeb.Shop.CustomPage do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.CustomPage do + @moduledoc """ + Custom (CMS) page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 2, assign: 3] alias Berrypod.Pages - @impl true - def mount(_params, _session, socket) do - {:ok, socket} + def init(socket, _params, _uri) do + # Custom pages load in handle_params based on slug + {:noreply, socket} end - @impl true def handle_params(%{"slug" => slug}, _uri, socket) do page = Pages.get_page(slug) @@ -38,12 +41,7 @@ defmodule BerrypodWeb.Shop.CustomPage do end end - @impl true - def render(assigns) do - ~H""" - - """ - end + def handle_event(_event, _params, _socket), do: :cont defp record_broken_url(path) do prior_hits = Berrypod.Analytics.count_pageviews_for_path(path) diff --git a/lib/berrypod_web/live/shop/home.ex b/lib/berrypod_web/live/shop/pages/home.ex similarity index 63% rename from lib/berrypod_web/live/shop/home.ex rename to lib/berrypod_web/live/shop/pages/home.ex index 067cf81..dcbf4cf 100644 --- a/lib/berrypod_web/live/shop/home.ex +++ b/lib/berrypod_web/live/shop/pages/home.ex @@ -1,10 +1,13 @@ -defmodule BerrypodWeb.Shop.Home do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.Home do + @moduledoc """ + Home page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 2, assign: 3] alias Berrypod.Pages - @impl true - def mount(_params, _session, socket) do + def init(socket, _params, _uri) do page = Pages.get_page("home") extra = Pages.load_block_data(page.blocks, socket.assigns) @@ -30,13 +33,12 @@ defmodule BerrypodWeb.Shop.Home do |> assign(:page, page) |> assign(extra) - {:ok, socket} + {:noreply, socket} end - @impl true - def render(assigns) do - ~H""" - - """ + def handle_params(_params, _uri, socket) do + {:noreply, socket} end + + def handle_event(_event, _params, _socket), do: :cont end diff --git a/lib/berrypod_web/live/shop/order_detail.ex b/lib/berrypod_web/live/shop/pages/order_detail.ex similarity index 65% rename from lib/berrypod_web/live/shop/order_detail.ex rename to lib/berrypod_web/live/shop/pages/order_detail.ex index ca8eb9b..91a5f24 100644 --- a/lib/berrypod_web/live/shop/order_detail.ex +++ b/lib/berrypod_web/live/shop/pages/order_detail.ex @@ -1,12 +1,16 @@ -defmodule BerrypodWeb.Shop.OrderDetail do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.OrderDetail do + @moduledoc """ + Order detail page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [push_navigate: 2] alias Berrypod.{Orders, Pages} alias Berrypod.Products alias Berrypod.Products.ProductImage - @impl true - def mount(_params, session, socket) do + def init(socket, _params, _uri, session) do page = Pages.get_page("order_detail") socket = @@ -14,10 +18,9 @@ defmodule BerrypodWeb.Shop.OrderDetail do |> assign(:lookup_email, session["order_lookup_email"]) |> assign(:page, page) - {:ok, socket} + {:noreply, socket} end - @impl true def handle_params(%{"order_number" => order_number}, _uri, socket) do email = socket.assigns.lookup_email @@ -43,20 +46,17 @@ defmodule BerrypodWeb.Shop.OrderDetail do {id, %{thumb: thumb, slug: slug}} end) - {:noreply, - socket - |> assign(:page_title, "Order #{order_number}") - |> assign(:order, order) - |> assign(:thumbnails, thumbnails)} + socket = + socket + |> assign(:page_title, "Order #{order_number}") + |> assign(:order, order) + |> assign(:thumbnails, thumbnails) + + {:noreply, socket} else - {:noreply, push_navigate(socket, to: ~p"/orders")} + {:noreply, push_navigate(socket, to: "/orders")} end end - @impl true - def render(assigns) do - ~H""" - - """ - end + def handle_event(_event, _params, _socket), do: :cont end diff --git a/lib/berrypod_web/live/shop/orders.ex b/lib/berrypod_web/live/shop/pages/orders.ex similarity index 53% rename from lib/berrypod_web/live/shop/orders.ex rename to lib/berrypod_web/live/shop/pages/orders.ex index 1ceafd0..b3c3852 100644 --- a/lib/berrypod_web/live/shop/orders.ex +++ b/lib/berrypod_web/live/shop/pages/orders.ex @@ -1,10 +1,13 @@ -defmodule BerrypodWeb.Shop.Orders do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.Orders do + @moduledoc """ + Orders list page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 3] alias Berrypod.{Orders, Pages} - @impl true - def mount(_params, session, socket) do + def init(socket, _params, _uri, session) do email = session["order_lookup_email"] page = Pages.get_page("orders") @@ -21,16 +24,12 @@ defmodule BerrypodWeb.Shop.Orders do assign(socket, :orders, nil) end - {:ok, socket} + {:noreply, socket} end - @impl true - def handle_params(_params, _uri, socket), do: {:noreply, socket} - - @impl true - def render(assigns) do - ~H""" - - """ + def handle_params(_params, _uri, socket) do + {:noreply, socket} end + + def handle_event(_event, _params, _socket), do: :cont end diff --git a/lib/berrypod_web/live/shop/product_show.ex b/lib/berrypod_web/live/shop/pages/product.ex similarity index 92% rename from lib/berrypod_web/live/shop/product_show.ex rename to lib/berrypod_web/live/shop/pages/product.ex index b2ab828..3d7d621 100644 --- a/lib/berrypod_web/live/shop/product_show.ex +++ b/lib/berrypod_web/live/shop/pages/product.ex @@ -1,16 +1,20 @@ -defmodule BerrypodWeb.Shop.ProductShow do - use BerrypodWeb, :live_view +defmodule BerrypodWeb.Shop.Pages.Product do + @moduledoc """ + Product detail page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 2, assign: 3] + import Phoenix.LiveView, only: [connected?: 1, push_navigate: 2] alias Berrypod.{Analytics, Cart, Pages} alias Berrypod.Images.Optimizer alias Berrypod.Products alias Berrypod.Products.{Product, ProductImage} - @impl true - def mount(%{"id" => slug}, _session, socket) do + def init(socket, %{"id" => slug}, _uri) do case Products.get_visible_product(slug) do nil -> - {:ok, push_navigate(socket, to: ~p"/collections/all")} + {:noreply, push_navigate(socket, to: "/collections/all")} product -> all_images = @@ -61,11 +65,10 @@ defmodule BerrypodWeb.Shop.ProductShow do # Block data loaders (related_products, reviews) run after product is assigned extra = Pages.load_block_data(page.blocks, socket.assigns) - {:ok, assign(socket, extra)} + {:noreply, assign(socket, extra)} end end - @impl true def handle_params(params, _uri, socket) do if socket.assigns[:product] do {:noreply, apply_variant_params(params, socket)} @@ -74,6 +77,50 @@ defmodule BerrypodWeb.Shop.ProductShow do end end + def handle_event("increment_quantity", _params, socket) do + quantity = min(socket.assigns.quantity + 1, 99) + {:noreply, assign(socket, :quantity, quantity)} + end + + def handle_event("decrement_quantity", _params, socket) do + quantity = max(socket.assigns.quantity - 1, 1) + {:noreply, assign(socket, :quantity, quantity)} + end + + def handle_event("add_to_cart", _params, socket) do + variant = socket.assigns.selected_variant + + if variant do + cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity) + + if socket.assigns[:analytics_visitor_hash] do + Analytics.track_event( + "add_to_cart", + Map.put( + BerrypodWeb.AnalyticsHook.attrs(socket), + :pathname, + "/products/#{socket.assigns.product.slug}" + ) + ) + end + + socket = + socket + |> BerrypodWeb.CartHook.broadcast_and_update(cart) + |> assign(:quantity, 1) + |> assign(:cart_drawer_open, true) + |> assign(:cart_status, "#{socket.assigns.product.title} added to cart") + + {:noreply, socket} + else + {:noreply, socket} + end + end + + def handle_event(_event, _params, _socket), do: :cont + + # ── Variant selection logic ────────────────────────────────────────── + defp apply_variant_params(params, socket) do %{option_types: option_types, variants: variants, product: product, all_images: all_images} = socket.assigns @@ -149,7 +196,7 @@ defmodule BerrypodWeb.Shop.ProductShow do opt_type.values |> Enum.map(fn value -> params = Map.put(selected_options, opt_type.name, value.title) - {value.title, ~p"/products/#{slug}?#{params}"} + {value.title, "/products/#{slug}?#{URI.encode_query(params)}"} end) |> Map.new() @@ -221,55 +268,7 @@ defmodule BerrypodWeb.Shop.ProductShow do |> Enum.map(& &1.url) end - @impl true - def handle_event("increment_quantity", _params, socket) do - quantity = min(socket.assigns.quantity + 1, 99) - {:noreply, assign(socket, :quantity, quantity)} - end - - @impl true - def handle_event("decrement_quantity", _params, socket) do - quantity = max(socket.assigns.quantity - 1, 1) - {:noreply, assign(socket, :quantity, quantity)} - end - - @impl true - def handle_event("add_to_cart", _params, socket) do - variant = socket.assigns.selected_variant - - if variant do - cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity) - - if socket.assigns[:analytics_visitor_hash] do - Analytics.track_event( - "add_to_cart", - Map.put( - BerrypodWeb.AnalyticsHook.attrs(socket), - :pathname, - "/products/#{socket.assigns.product.slug}" - ) - ) - end - - socket = - socket - |> BerrypodWeb.CartHook.broadcast_and_update(cart) - |> assign(:quantity, 1) - |> assign(:cart_drawer_open, true) - |> assign(:cart_status, "#{socket.assigns.product.title} added to cart") - - {:noreply, socket} - else - {:noreply, socket} - end - end - - @impl true - def render(assigns) do - ~H""" - - """ - end + # ── JSON-LD and meta helpers ───────────────────────────────────────── defp product_json_ld(product, url, image, base) do category_slug = diff --git a/lib/berrypod_web/live/shop/pages/search.ex b/lib/berrypod_web/live/shop/pages/search.ex new file mode 100644 index 0000000..c84dc47 --- /dev/null +++ b/lib/berrypod_web/live/shop/pages/search.ex @@ -0,0 +1,39 @@ +defmodule BerrypodWeb.Shop.Pages.Search do + @moduledoc """ + Search page handler for the unified Shop.Page LiveView. + """ + + import Phoenix.Component, only: [assign: 3] + import Phoenix.LiveView, only: [push_patch: 2] + + alias Berrypod.{Pages, Search} + + def init(socket, _params, _uri) do + page = Pages.get_page("search") + + socket = + socket + |> assign(:page_title, "Search") + |> assign(:page, page) + + {:noreply, socket} + end + + def handle_params(params, _uri, socket) do + query = params["q"] || "" + results = if query != "", do: Search.search(query), else: [] + + socket = + socket + |> assign(:search_page_query, query) + |> assign(:search_page_results, results) + + {:noreply, socket} + end + + def handle_event("search_submit", %{"q" => query}, socket) do + {:noreply, push_patch(socket, to: "/search?q=#{query}")} + end + + def handle_event(_event, _params, _socket), do: :cont +end diff --git a/lib/berrypod_web/live/shop/search.ex b/lib/berrypod_web/live/shop/search.ex deleted file mode 100644 index 3d20534..0000000 --- a/lib/berrypod_web/live/shop/search.ex +++ /dev/null @@ -1,34 +0,0 @@ -defmodule BerrypodWeb.Shop.Search do - use BerrypodWeb, :live_view - - alias Berrypod.{Pages, Search} - - @impl true - def mount(_params, _session, socket) do - page = Pages.get_page("search") - {:ok, socket |> assign(:page_title, "Search") |> assign(:page, page)} - end - - @impl true - def handle_params(params, _uri, socket) do - query = params["q"] || "" - results = if query != "", do: Search.search(query), else: [] - - {:noreply, - socket - |> assign(:search_page_query, query) - |> assign(:search_page_results, results)} - end - - @impl true - def handle_event("search_submit", %{"q" => query}, socket) do - {:noreply, push_patch(socket, to: ~p"/search?q=#{query}")} - end - - @impl true - def render(assigns) do - ~H""" - - """ - end -end diff --git a/lib/berrypod_web/page_editor_hook.ex b/lib/berrypod_web/page_editor_hook.ex index d837478..ab37712 100644 --- a/lib/berrypod_web/page_editor_hook.ex +++ b/lib/berrypod_web/page_editor_hook.ex @@ -31,6 +31,7 @@ defmodule BerrypodWeb.PageEditorHook do # Page editing state |> assign(:editing, false) |> assign(:editing_blocks, nil) + |> assign(:editor_page_slug, nil) |> assign(:editor_dirty, false) |> assign(:editor_at_defaults, true) |> assign(:editor_history, []) @@ -67,13 +68,81 @@ defmodule BerrypodWeb.PageEditorHook do {:cont, socket} end - # ── handle_params: track current path ──────────────────────────── + # ── handle_params: track current path and restore editor state ──── - defp handle_editor_params(_params, uri, socket) do + defp handle_editor_params(params, uri, socket) do parsed = URI.parse(uri) - # Store the current path for reference (e.g. the Done button) - {:cont, assign(socket, :editor_current_path, parsed.path)} + socket = + socket + |> assign(:editor_current_path, parsed.path) + |> maybe_restore_editor_state(params) + + {:cont, socket} + end + + # Restore editor state from URL params on navigation + # Only activates state if not already in the requested state (avoids loops) + defp maybe_restore_editor_state(socket, params) do + if socket.assigns.is_admin do + requested_tab = params["edit"] + current_tab = socket.assigns.editor_active_tab + current_state = socket.assigns.editor_sheet_state + + # If already in the correct state, don't re-apply + already_correct? = + current_state == :open && requested_tab && + String.to_existing_atom(requested_tab) == current_tab + + if already_correct? do + socket + else + case requested_tab do + "theme" -> + socket + |> assign(:editor_sheet_state, :open) + |> assign(:editor_active_tab, :theme) + |> maybe_enter_theme_mode() + + "page" -> + socket + |> assign(:editor_sheet_state, :open) + |> assign(:editor_active_tab, :page) + |> maybe_enter_page_mode() + + "settings" -> + socket + |> assign(:editor_sheet_state, :open) + |> assign(:editor_active_tab, :settings) + |> maybe_enter_theme_mode() + + _ -> + socket + end + end + else + socket + end + end + + defp maybe_enter_theme_mode(socket) do + if socket.assigns.theme_editing do + socket + else + load_theme_state(socket) + end + end + + defp maybe_enter_page_mode(socket) do + if socket.assigns.editing do + socket + else + if socket.assigns[:page] do + enter_edit_mode(socket) + else + socket + end + end end # ── handle_info ───────────────────────────────────────────────── @@ -703,6 +772,7 @@ defmodule BerrypodWeb.PageEditorHook do socket |> assign(:editing, true) |> assign(:editing_blocks, page.blocks) + |> assign(:editor_page_slug, page.slug) |> assign(:editor_dirty, false) |> assign(:editor_at_defaults, at_defaults) |> assign(:editor_history, []) @@ -725,6 +795,7 @@ defmodule BerrypodWeb.PageEditorHook do socket |> assign(:editing, false) |> assign(:editing_blocks, nil) + |> assign(:editor_page_slug, nil) |> assign(:editor_dirty, false) |> assign(:editor_history, []) |> assign(:editor_future, []) diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index 96e56a2..7d19c6d 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -178,7 +178,7 @@ defmodule BerrypodWeb.PageRenderer do """ end - # Theme editor content - shows theme controls + # Theme editor content - uses shared component attr :theme_editor_settings, :map, default: nil attr :theme_editor_active_preset, :atom, default: nil attr :theme_editor_presets, :list, default: [] @@ -187,131 +187,14 @@ defmodule BerrypodWeb.PageRenderer do defp theme_editor_content(assigns) do ~H""" -
    - <%= if @theme_editor_settings do %> - <%!-- Shop name --%> -
    - -
    - -
    -
    - - <%!-- Presets --%> -
    - -
    - <%= for {preset_name, description} <- @theme_editor_presets do %> - - <% end %> -
    -
    - - <%!-- Mood --%> -
    - -
    - <%= for mood <- ["warm", "neutral", "cool", "dark"] do %> - - <% end %> -
    -
    - - <%!-- Typography --%> -
    - -
    - <%= for typo <- ["clean", "editorial", "modern", "classic", "friendly", "minimal"] do %> - - <% end %> -
    -
    - - <%!-- Shape --%> -
    - -
    - <%= for shape <- ["sharp", "soft", "round", "pill"] do %> - - <% end %> -
    -
    - - <%!-- More options link --%> -
    - - More options - - - - -
    -

    - For full theme customisation including branding, colours, and layout, visit the theme editor. -

    -
    -
    - <% else %> -

    Loading theme settings...

    - <% end %> -
    + <.compact_editor + theme_settings={@theme_editor_settings} + active_preset={@theme_editor_active_preset} + presets={@theme_editor_presets} + site_name={@site_name} + customise_open={@theme_editor_customise_open} + event_prefix="theme_" + /> """ end @@ -727,7 +610,7 @@ defmodule BerrypodWeb.PageRenderer do
    • <.link - navigate={collection_path("all", @current_sort)} + patch={collection_path("all", @current_sort)} aria-current={@current_slug == nil && "page"} class={["collection-filter-pill", @current_slug == nil && "active"]} > @@ -736,7 +619,7 @@ defmodule BerrypodWeb.PageRenderer do
    • <.link - navigate={collection_path("sale", @current_sort)} + patch={collection_path("sale", @current_sort)} aria-current={@current_slug == "sale" && "page"} class={["collection-filter-pill", @current_slug == "sale" && "active"]} > @@ -746,7 +629,7 @@ defmodule BerrypodWeb.PageRenderer do <%= for category <- assigns[:categories] || [] do %>
    • <.link - navigate={collection_path(category.slug, @current_sort)} + patch={collection_path(category.slug, @current_sort)} aria-current={@current_slug == category.slug && "page"} class={["collection-filter-pill", @current_slug == category.slug && "active"]} > @@ -813,7 +696,7 @@ defmodule BerrypodWeb.PageRenderer do <%= if (assigns[:products] || []) == [] do %>

      No products found in this collection.

      - <.link navigate={~p"/collections/all"} class="collection-empty-link"> + <.link patch={~p"/collections/all"} class="collection-empty-link"> View all products
      @@ -1020,7 +903,7 @@ defmodule BerrypodWeb.PageRenderer do Please wait while we confirm your payment. This usually takes a few seconds.

      - If this page doesn't update, please <.link navigate="/contact" class="checkout-contact-link">contact us. + If this page doesn't update, please <.link patch="/contact" class="checkout-contact-link">contact us.

      <% end %> @@ -1045,20 +928,20 @@ defmodule BerrypodWeb.PageRenderer do

      This link has expired or is invalid.

      - Head back to the <.link navigate="/contact">contact page to request a new one. + Head back to the <.link patch="/contact">contact page to request a new one.

      <% assigns[:orders] == [] -> %>

      No orders found for that email address.

      - If something doesn't look right, <.link navigate="/contact">get in touch. + If something doesn't look right, <.link patch="/contact">get in touch.

      <% true -> %>
      <%= for order <- assigns[:orders] do %> - <.link navigate={"/orders/#{order.order_number}"} class="order-summary-card"> + <.link patch={"/orders/#{order.order_number}"} class="order-summary-card">

      {order.order_number}

      @@ -1100,7 +983,7 @@ defmodule BerrypodWeb.PageRenderer do ~H""" <%= if assigns[:order] do %>
      - <.link navigate="/orders" class="order-detail-back">← Back to orders + <.link patch="/orders" class="order-detail-back">← Back to orders

      {assigns[:order].order_number}

      {Calendar.strftime(assigns[:order].inserted_at, "%-d %B %Y")}

      @@ -1155,7 +1038,7 @@ defmodule BerrypodWeb.PageRenderer do
      <%= if info && info.slug do %> <.link - navigate={"/products/#{info.slug}"} + patch={"/products/#{info.slug}"} class="checkout-item-name checkout-item-link" > {item.product_name} @@ -1245,7 +1128,7 @@ defmodule BerrypodWeb.PageRenderer do <%= if (assigns[:search_page_query] || "") != "" do %>

      No products found for “{assigns[:search_page_query]}”

      - <.link navigate="/collections/all" class="collection-empty-link">Browse all products + <.link patch="/collections/all" class="collection-empty-link">Browse all products
      <% end %> <% end %> @@ -1285,7 +1168,7 @@ defmodule BerrypodWeb.PageRenderer do ~H"""
      <.link - navigate={@href} + patch={@href} class={if @btn_style == "outline", do: "themed-button-outline", else: "themed-button"} > {@text} diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index 8308a27..00f3837 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -279,22 +279,22 @@ defmodule BerrypodWeb.Router do {BerrypodWeb.PageEditorHook, :mount_page_editor}, {BerrypodWeb.NewsletterHook, :mount_newsletter} ] do - live "/", Shop.Home, :index - live "/about", Shop.Content, :about - live "/delivery", Shop.Content, :delivery - live "/privacy", Shop.Content, :privacy - live "/terms", Shop.Content, :terms - live "/contact", Shop.Contact, :index - live "/collections/:slug", Shop.Collection, :show - live "/products/:id", Shop.ProductShow, :show - live "/cart", Shop.Cart, :index - live "/search", Shop.Search, :index - live "/checkout/success", Shop.CheckoutSuccess, :show - live "/orders", Shop.Orders, :index - live "/orders/:order_number", Shop.OrderDetail, :show + live "/", Shop.Page, :home + live "/about", Shop.Page, :about + live "/delivery", Shop.Page, :delivery + live "/privacy", Shop.Page, :privacy + live "/terms", Shop.Page, :terms + live "/contact", Shop.Page, :contact + live "/collections/:slug", Shop.Page, :collection + live "/products/:id", Shop.Page, :product + live "/cart", Shop.Page, :cart + live "/search", Shop.Page, :search + live "/checkout/success", Shop.Page, :checkout_success + live "/orders", Shop.Page, :orders + live "/orders/:order_number", Shop.Page, :order_detail # Catch-all for custom CMS pages — must be last - live "/:slug", Shop.CustomPage, :show + live "/:slug", Shop.Page, :custom_page end # Checkout (POST — creates Stripe session and redirects) diff --git a/test/berrypod_web/live/shop/search_integration_test.exs b/test/berrypod_web/live/shop/search_integration_test.exs index b3e1600..f6495de 100644 --- a/test/berrypod_web/live/shop/search_integration_test.exs +++ b/test/berrypod_web/live/shop/search_integration_test.exs @@ -86,13 +86,13 @@ defmodule BerrypodWeb.Shop.SearchIntegrationTest do end describe "search results rendering" do - test "result links use navigate for LiveView navigation", %{conn: conn, mountain: mountain} do + test "result links use patch for LiveView navigation", %{conn: conn, mountain: mountain} do {:ok, view, _html} = live(conn, ~p"/") html = render_hook(view, "search", %{"value" => "mountain"}) assert html =~ ~s(href="/products/#{mountain.slug}") - assert html =~ ~s(data-phx-link="redirect") + assert html =~ ~s(data-phx-link="patch") end test "results have ARIA listbox and option roles", %{conn: conn} do