consolidate shop pages into unified LiveView for editor state persistence
All checks were successful
deploy / deploy (push) Successful in 1m27s
All checks were successful
deploy / deploy (push) Successful in 1m27s
Replace individual shop LiveViews with a single Shop.Page that dispatches to page modules based on live_action. This enables patch navigation between pages, preserving socket state (including editor state) across transitions. Changes: - Add Shop.Page unified LiveView with handle_params dispatch - Extract page logic into Shop.Pages.* modules (Home, Product, Collection, etc.) - Update router to use Shop.Page with live_action for all shop routes - Change navigate= to patch= in shop component links - Add maybe_sync_editing_blocks to reload editor state when page changes - Track editor_page_slug to detect cross-page navigation while editing - Fix picture element height when hover image disabled - Extract ThemeEditor components for shared use Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
31
lib/berrypod_web/live/shop/pages/cart.ex
Normal file
31
lib/berrypod_web/live/shop/pages/cart.ex
Normal file
@@ -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
|
||||
82
lib/berrypod_web/live/shop/pages/checkout_success.ex
Normal file
82
lib/berrypod_web/live/shop/pages/checkout_success.ex
Normal file
@@ -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
|
||||
105
lib/berrypod_web/live/shop/pages/collection.ex
Normal file
105
lib/berrypod_web/live/shop/pages/collection.ex
Normal file
@@ -0,0 +1,105 @@
|
||||
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}
|
||||
|
||||
@sort_options [
|
||||
{"featured", "Featured"},
|
||||
{"newest", "Newest"},
|
||||
{"price_asc", "Price: Low to High"},
|
||||
{"price_desc", "Price: High to Low"},
|
||||
{"name_asc", "Name: A-Z"},
|
||||
{"name_desc", "Name: Z-A"}
|
||||
]
|
||||
|
||||
def init(socket, _params, _uri) do
|
||||
page = Pages.get_page("collection")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page, page)
|
||||
|> assign(:sort_options, @sort_options)
|
||||
|> assign(:current_sort, "featured")
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
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} ->
|
||||
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 ->
|
||||
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}
|
||||
end
|
||||
|
||||
defp load_collection("sale", sort, page) do
|
||||
pagination = Products.list_visible_products_paginated(on_sale: true, sort: sort, page: page)
|
||||
{:ok, "Sale", :sale, pagination}
|
||||
end
|
||||
|
||||
defp load_collection(slug, sort, page) do
|
||||
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
|
||||
nil ->
|
||||
:not_found
|
||||
|
||||
category ->
|
||||
pagination =
|
||||
Products.list_visible_products_paginated(
|
||||
category: category.name,
|
||||
sort: sort,
|
||||
page: page
|
||||
)
|
||||
|
||||
{:ok, category.name, category, pagination}
|
||||
end
|
||||
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."
|
||||
end
|
||||
72
lib/berrypod_web/live/shop/pages/contact.ex
Normal file
72
lib/berrypod_web/live/shop/pages/contact.ex
Normal file
@@ -0,0 +1,72 @@
|
||||
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
|
||||
|
||||
def init(socket, _params, _uri) do
|
||||
page = Pages.get_page("contact")
|
||||
|
||||
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
|
||||
|
||||
def handle_event("lookup_orders", %{"email" => email}, socket) do
|
||||
orders = Orders.list_orders_by_email(email)
|
||||
|
||||
state =
|
||||
if orders == [] do
|
||||
:not_found
|
||||
else
|
||||
token = OrderLookupController.generate_token(email)
|
||||
link = BerrypodWeb.Endpoint.url() <> "/orders/verify/#{token}"
|
||||
OrderNotifier.deliver_order_lookup(email, link)
|
||||
:sent
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :tracking_state, state)}
|
||||
end
|
||||
|
||||
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: "/contact")}
|
||||
|
||||
{:error, :invalid_params} ->
|
||||
{:noreply, put_flash(socket, :error, "Please fill in all required fields.")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Sorry, something went wrong. Please try again.")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("reset_tracking", _params, socket) do
|
||||
{:noreply, assign(socket, :tracking_state, :idle)}
|
||||
end
|
||||
|
||||
def handle_event(_event, _params, _socket), do: :cont
|
||||
end
|
||||
79
lib/berrypod_web/live/shop/pages/content.ex
Normal file
79
lib/berrypod_web/live/shop/pages/content.ex
Normal file
@@ -0,0 +1,79 @@
|
||||
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
|
||||
|
||||
def init(socket, _params, _uri) do
|
||||
# Content pages load in handle_params based on live_action
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(_params, _uri, socket) do
|
||||
action = socket.assigns.live_action
|
||||
slug = to_string(action)
|
||||
page = Pages.get_page(slug)
|
||||
{seo, content_blocks} = page_config(action)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(seo)
|
||||
|> assign(:page, page)
|
||||
|> assign(:content_blocks, content_blocks)
|
||||
|
||||
{:noreply, socket}
|
||||
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 – this is sample content for the demo shop",
|
||||
og_url: BerrypodWeb.Endpoint.url() <> "/about"
|
||||
},
|
||||
PreviewData.about_content()
|
||||
}
|
||||
end
|
||||
|
||||
defp page_config(:delivery) do
|
||||
{
|
||||
%{
|
||||
page_title: "Delivery & returns",
|
||||
page_description: "Everything you need to know about shipping and returns.",
|
||||
og_url: BerrypodWeb.Endpoint.url() <> "/delivery"
|
||||
},
|
||||
LegalPages.delivery_content()
|
||||
}
|
||||
end
|
||||
|
||||
defp page_config(:privacy) do
|
||||
{
|
||||
%{
|
||||
page_title: "Privacy policy",
|
||||
page_description: "How we handle your personal information.",
|
||||
og_url: BerrypodWeb.Endpoint.url() <> "/privacy"
|
||||
},
|
||||
LegalPages.privacy_content()
|
||||
}
|
||||
end
|
||||
|
||||
defp page_config(:terms) do
|
||||
{
|
||||
%{
|
||||
page_title: "Terms of service",
|
||||
page_description: "The terms and conditions governing purchases from our shop.",
|
||||
og_url: BerrypodWeb.Endpoint.url() <> "/terms"
|
||||
},
|
||||
LegalPages.terms_content()
|
||||
}
|
||||
end
|
||||
end
|
||||
68
lib/berrypod_web/live/shop/pages/custom_page.ex
Normal file
68
lib/berrypod_web/live/shop/pages/custom_page.ex
Normal file
@@ -0,0 +1,68 @@
|
||||
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
|
||||
|
||||
def init(socket, _params, _uri) do
|
||||
# Custom pages load in handle_params based on slug
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(%{"slug" => slug}, _uri, socket) do
|
||||
page = Pages.get_page(slug)
|
||||
|
||||
cond do
|
||||
is_nil(page) ->
|
||||
record_broken_url("/#{slug}")
|
||||
raise BerrypodWeb.NotFoundError
|
||||
|
||||
page.type != "custom" ->
|
||||
raise BerrypodWeb.NotFoundError
|
||||
|
||||
page.published != true and not socket.assigns.is_admin ->
|
||||
raise BerrypodWeb.NotFoundError
|
||||
|
||||
true ->
|
||||
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, page.title)
|
||||
|> assign(:page, page)
|
||||
|> maybe_assign_meta(page, base)
|
||||
|> assign(extra)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(_event, _params, _socket), do: :cont
|
||||
|
||||
defp record_broken_url(path) do
|
||||
prior_hits = Berrypod.Analytics.count_pageviews_for_path(path)
|
||||
Berrypod.Redirects.record_broken_url(path, prior_hits)
|
||||
|
||||
if prior_hits > 0 do
|
||||
Berrypod.Redirects.attempt_auto_resolve(path)
|
||||
end
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
defp maybe_assign_meta(socket, page, base) do
|
||||
socket
|
||||
|> assign(:og_url, base <> "/#{page.slug}")
|
||||
|> then(fn s ->
|
||||
if page.meta_description do
|
||||
assign(s, :page_description, page.meta_description)
|
||||
else
|
||||
s
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
44
lib/berrypod_web/live/shop/pages/home.ex
Normal file
44
lib/berrypod_web/live/shop/pages/home.ex
Normal file
@@ -0,0 +1,44 @@
|
||||
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
|
||||
|
||||
def init(socket, _params, _uri) do
|
||||
page = Pages.get_page("home")
|
||||
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
||||
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
site_name = socket.assigns.site_name
|
||||
|
||||
org_ld =
|
||||
Jason.encode!(
|
||||
%{
|
||||
"@context" => "https://schema.org",
|
||||
"@type" => "Organization",
|
||||
"name" => site_name,
|
||||
"url" => base <> "/"
|
||||
},
|
||||
escape: :html_safe
|
||||
)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Home")
|
||||
|> assign(:og_url, base <> "/")
|
||||
|> assign(:json_ld, org_ld)
|
||||
|> assign(:page, page)
|
||||
|> assign(extra)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(_params, _uri, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event(_event, _params, _socket), do: :cont
|
||||
end
|
||||
62
lib/berrypod_web/live/shop/pages/order_detail.ex
Normal file
62
lib/berrypod_web/live/shop/pages/order_detail.ex
Normal file
@@ -0,0 +1,62 @@
|
||||
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
|
||||
|
||||
def init(socket, _params, _uri, session) do
|
||||
page = Pages.get_page("order_detail")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:lookup_email, session["order_lookup_email"])
|
||||
|> assign(:page, page)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(%{"order_number" => order_number}, _uri, socket) do
|
||||
email = socket.assigns.lookup_email
|
||||
|
||||
order = Orders.get_order_by_number(order_number)
|
||||
|
||||
normalised = fn e -> String.downcase(String.trim(e || "")) end
|
||||
|
||||
if order && order.payment_status == "paid" &&
|
||||
email && normalised.(order.customer_email) == normalised.(email) do
|
||||
variant_ids = Enum.map(order.items, & &1.variant_id)
|
||||
variants = Products.get_variants_with_products(variant_ids)
|
||||
|
||||
thumbnails =
|
||||
Map.new(variants, fn {id, variant} ->
|
||||
thumb =
|
||||
case variant.product.images do
|
||||
[first | _] -> ProductImage.thumbnail_url(first)
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
slug = if variant.product.visible, do: variant.product.slug, else: nil
|
||||
|
||||
{id, %{thumb: thumb, slug: slug}}
|
||||
end)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Order #{order_number}")
|
||||
|> assign(:order, order)
|
||||
|> assign(:thumbnails, thumbnails)
|
||||
|
||||
{:noreply, socket}
|
||||
else
|
||||
{:noreply, push_navigate(socket, to: "/orders")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event(_event, _params, _socket), do: :cont
|
||||
end
|
||||
35
lib/berrypod_web/live/shop/pages/orders.ex
Normal file
35
lib/berrypod_web/live/shop/pages/orders.ex
Normal file
@@ -0,0 +1,35 @@
|
||||
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}
|
||||
|
||||
def init(socket, _params, _uri, session) do
|
||||
email = session["order_lookup_email"]
|
||||
page = Pages.get_page("orders")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Your orders")
|
||||
|> assign(:lookup_email, email)
|
||||
|> assign(:page, page)
|
||||
|
||||
socket =
|
||||
if email do
|
||||
assign(socket, :orders, Orders.list_orders_by_email(email))
|
||||
else
|
||||
assign(socket, :orders, nil)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_params(_params, _uri, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event(_event, _params, _socket), do: :cont
|
||||
end
|
||||
357
lib/berrypod_web/live/shop/pages/product.ex
Normal file
357
lib/berrypod_web/live/shop/pages/product.ex
Normal file
@@ -0,0 +1,357 @@
|
||||
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}
|
||||
|
||||
def init(socket, %{"id" => slug}, _uri) do
|
||||
case Products.get_visible_product(slug) do
|
||||
nil ->
|
||||
{:noreply, push_navigate(socket, to: "/collections/all")}
|
||||
|
||||
product ->
|
||||
all_images =
|
||||
(product.images || [])
|
||||
|> Enum.sort_by(& &1.position)
|
||||
|> Enum.map(fn img ->
|
||||
width =
|
||||
case ProductImage.source_width(img) do
|
||||
nil -> 1200
|
||||
sw -> Enum.max(Optimizer.applicable_widths(sw))
|
||||
end
|
||||
|
||||
%{url: ProductImage.url(img, width), color: img.color}
|
||||
end)
|
||||
|> Enum.reject(fn img -> is_nil(img.url) end)
|
||||
|
||||
option_types = Product.option_types(product)
|
||||
variants = product.variants || []
|
||||
|
||||
if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
|
||||
Analytics.track_event(
|
||||
"product_view",
|
||||
Map.put(BerrypodWeb.AnalyticsHook.attrs(socket), :pathname, "/products/#{slug}")
|
||||
)
|
||||
end
|
||||
|
||||
base = BerrypodWeb.Endpoint.url()
|
||||
og_url = base <> "/products/#{slug}"
|
||||
og_image = og_image_url(all_images)
|
||||
|
||||
page = Pages.get_page("pdp")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, product.title)
|
||||
|> assign(:page_description, meta_description(product.description))
|
||||
|> assign(:og_type, "product")
|
||||
|> assign(:og_url, og_url)
|
||||
|> assign(:og_image, og_image)
|
||||
|> assign(:json_ld, product_json_ld(product, og_url, og_image, base))
|
||||
|> assign(:product, product)
|
||||
|> assign(:all_images, all_images)
|
||||
|> assign(:quantity, 1)
|
||||
|> assign(:option_types, option_types)
|
||||
|> assign(:variants, variants)
|
||||
|> assign(:page, page)
|
||||
|
||||
# Block data loaders (related_products, reviews) run after product is assigned
|
||||
extra = Pages.load_block_data(page.blocks, socket.assigns)
|
||||
|
||||
{:noreply, assign(socket, extra)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_params(params, _uri, socket) do
|
||||
if socket.assigns[:product] do
|
||||
{:noreply, apply_variant_params(params, socket)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
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
|
||||
|
||||
option_names = Enum.map(option_types, & &1.name)
|
||||
|
||||
default_options =
|
||||
case variants do
|
||||
[first | _] -> first.options
|
||||
[] -> %{}
|
||||
end
|
||||
|
||||
# Extract option params from the URL, ignoring unknown keys
|
||||
param_options =
|
||||
Enum.reduce(option_names, %{}, fn name, acc ->
|
||||
case params[name] do
|
||||
nil -> acc
|
||||
value -> Map.put(acc, name, value)
|
||||
end
|
||||
end)
|
||||
|
||||
{selected_options, changed_option} =
|
||||
if map_size(param_options) == 0 do
|
||||
{default_options, nil}
|
||||
else
|
||||
merged = Map.merge(default_options, param_options)
|
||||
|
||||
# Identify which option was changed from the default
|
||||
changed =
|
||||
Enum.find(option_names, fn name ->
|
||||
Map.has_key?(param_options, name) and param_options[name] != default_options[name]
|
||||
end)
|
||||
|
||||
{merged, changed}
|
||||
end
|
||||
|
||||
selected_options =
|
||||
if changed_option do
|
||||
resolve_valid_combo(variants, option_types, selected_options, changed_option)
|
||||
else
|
||||
selected_options
|
||||
end
|
||||
|
||||
selected_variant = find_variant(variants, selected_options)
|
||||
|
||||
# If params produced an invalid combo with no matching variant, fall back to defaults
|
||||
{selected_options, selected_variant} =
|
||||
if is_nil(selected_variant) and variants != [] do
|
||||
opts = List.first(variants).options
|
||||
{opts, List.first(variants)}
|
||||
else
|
||||
{selected_options, selected_variant}
|
||||
end
|
||||
|
||||
available_options = compute_available_options(option_types, variants, selected_options)
|
||||
display_price = variant_price(selected_variant, product)
|
||||
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
|
||||
|
||||
option_urls = build_option_urls(option_types, selected_options, product.slug)
|
||||
|
||||
socket
|
||||
|> assign(:selected_options, selected_options)
|
||||
|> assign(:selected_variant, selected_variant)
|
||||
|> assign(:available_options, available_options)
|
||||
|> assign(:display_price, display_price)
|
||||
|> assign(:gallery_images, gallery_images)
|
||||
|> assign(:option_urls, option_urls)
|
||||
end
|
||||
|
||||
defp build_option_urls(option_types, selected_options, slug) do
|
||||
Enum.reduce(option_types, %{}, fn opt_type, acc ->
|
||||
urls =
|
||||
opt_type.values
|
||||
|> Enum.map(fn value ->
|
||||
params = Map.put(selected_options, opt_type.name, value.title)
|
||||
{value.title, "/products/#{slug}?#{URI.encode_query(params)}"}
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
Map.put(acc, opt_type.name, urls)
|
||||
end)
|
||||
end
|
||||
|
||||
defp compute_available_options(option_types, variants, selected_options) do
|
||||
Enum.reduce(option_types, %{}, fn opt_type, acc ->
|
||||
other_options = Map.delete(selected_options, opt_type.name)
|
||||
|
||||
available_values =
|
||||
variants
|
||||
|> Enum.filter(fn v ->
|
||||
v.is_available &&
|
||||
Enum.all?(other_options, fn {k, selected_val} ->
|
||||
v.options[k] == selected_val
|
||||
end)
|
||||
end)
|
||||
|> Enum.map(fn v -> v.options[opt_type.name] end)
|
||||
|> Enum.uniq()
|
||||
|
||||
Map.put(acc, opt_type.name, available_values)
|
||||
end)
|
||||
end
|
||||
|
||||
defp variant_price(%{price: price}, _product) when is_integer(price), do: price
|
||||
defp variant_price(_, %{cheapest_price: price}), do: price
|
||||
defp variant_price(_, _), do: 0
|
||||
|
||||
# If the current combo doesn't match any variant, auto-adjust other options
|
||||
# to find a valid one. Keeps the just-changed option fixed, adjusts the rest.
|
||||
defp resolve_valid_combo(variants, option_types, selected_options, changed_option) do
|
||||
if Enum.any?(variants, fn v -> v.options == selected_options end) do
|
||||
selected_options
|
||||
else
|
||||
matching =
|
||||
Enum.filter(variants, fn v ->
|
||||
v.is_available && v.options[changed_option] == selected_options[changed_option]
|
||||
end)
|
||||
|
||||
case matching do
|
||||
[first | _] ->
|
||||
Enum.reduce(option_types, selected_options, fn opt_type, acc ->
|
||||
if opt_type.name == changed_option do
|
||||
acc
|
||||
else
|
||||
Map.put(acc, opt_type.name, first.options[opt_type.name])
|
||||
end
|
||||
end)
|
||||
|
||||
[] ->
|
||||
selected_options
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp find_variant(variants, selected_options) do
|
||||
Enum.find(variants, fn v -> v.options == selected_options end)
|
||||
end
|
||||
|
||||
defp filter_gallery_images(all_images, selected_color) do
|
||||
if selected_color do
|
||||
color_images = Enum.filter(all_images, &(&1.color == selected_color))
|
||||
if color_images == [], do: all_images, else: color_images
|
||||
else
|
||||
all_images
|
||||
end
|
||||
|> Enum.map(& &1.url)
|
||||
end
|
||||
|
||||
# ── JSON-LD and meta helpers ─────────────────────────────────────────
|
||||
|
||||
defp product_json_ld(product, url, image, base) do
|
||||
category_slug =
|
||||
if product.category,
|
||||
do: product.category |> String.downcase() |> String.replace(" ", "-"),
|
||||
else: "all"
|
||||
|
||||
breadcrumbs =
|
||||
[
|
||||
%{"@type" => "ListItem", "position" => 1, "name" => "Home", "item" => base <> "/"},
|
||||
product.category &&
|
||||
%{
|
||||
"@type" => "ListItem",
|
||||
"position" => 2,
|
||||
"name" => product.category,
|
||||
"item" => base <> "/collections/#{category_slug}"
|
||||
},
|
||||
%{
|
||||
"@type" => "ListItem",
|
||||
"position" => if(product.category, do: 3, else: 2),
|
||||
"name" => product.title,
|
||||
"item" => url
|
||||
}
|
||||
]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|
||||
data = %{
|
||||
"@context" => "https://schema.org",
|
||||
"@graph" => [
|
||||
%{
|
||||
"@type" => "Product",
|
||||
"name" => product.title,
|
||||
"description" => plain_text(product.description),
|
||||
"image" => Enum.reject([image], &is_nil/1),
|
||||
"url" => url,
|
||||
"offers" => %{
|
||||
"@type" => "Offer",
|
||||
"price" => format_price(product.cheapest_price),
|
||||
"priceCurrency" => "GBP",
|
||||
"availability" =>
|
||||
if(product.in_stock,
|
||||
do: "https://schema.org/InStock",
|
||||
else: "https://schema.org/OutOfStock"
|
||||
),
|
||||
"url" => url
|
||||
}
|
||||
},
|
||||
%{
|
||||
"@type" => "BreadcrumbList",
|
||||
"itemListElement" => breadcrumbs
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Jason.encode!(data, escape: :html_safe)
|
||||
end
|
||||
|
||||
defp format_price(pence) when is_integer(pence) do
|
||||
"#{div(pence, 100)}.#{String.pad_leading(to_string(rem(pence, 100)), 2, "0")}"
|
||||
end
|
||||
|
||||
defp format_price(_), do: "0.00"
|
||||
|
||||
defp plain_text(nil), do: nil
|
||||
defp plain_text(text), do: String.replace(text, ~r/<[^>]+>/, "")
|
||||
|
||||
defp og_image_url([%{url: "/" <> _ = path} | _]), do: BerrypodWeb.Endpoint.url() <> path
|
||||
defp og_image_url(_), do: nil
|
||||
|
||||
defp meta_description(nil), do: nil
|
||||
|
||||
defp meta_description(text) do
|
||||
plain = String.replace(text, ~r/<[^>]+>/, "")
|
||||
|
||||
if String.length(plain) <= 155 do
|
||||
plain
|
||||
else
|
||||
plain
|
||||
|> String.slice(0, 155)
|
||||
|> String.split(~r/\s+/)
|
||||
|> Enum.drop(-1)
|
||||
|> Enum.join(" ")
|
||||
|> Kernel.<>("…")
|
||||
end
|
||||
end
|
||||
end
|
||||
39
lib/berrypod_web/live/shop/pages/search.ex
Normal file
39
lib/berrypod_web/live/shop/pages/search.ex
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user