add custom page LiveView with catch-all routing
Shop.CustomPage handles /:slug catch-all for CMS pages. Restructured router so the catch-all is last — all admin, auth, setup, and SEO routes defined before the shop scope to prevent interception. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cf627bd585
commit
ad2f2517e5
80
lib/berrypod_web/live/shop/custom_page.ex
Normal file
80
lib/berrypod_web/live/shop/custom_page.ex
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
defmodule BerrypodWeb.Shop.CustomPage do
|
||||||
|
use BerrypodWeb, :live_view
|
||||||
|
|
||||||
|
alias Berrypod.Pages
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_params(%{"slug" => slug}, _uri, socket) do
|
||||||
|
page = Pages.get_page(slug)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
is_nil(page) ->
|
||||||
|
record_broken_url("/#{slug}")
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Page not found")
|
||||||
|
|> push_navigate(to: ~p"/")}
|
||||||
|
|
||||||
|
page.type != "custom" ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Page not found")
|
||||||
|
|> push_navigate(to: ~p"/")}
|
||||||
|
|
||||||
|
page.published != true and not socket.assigns.is_admin ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:error, "Page not found")
|
||||||
|
|> push_navigate(to: ~p"/")}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<BerrypodWeb.PageRenderer.render_page {assigns} />
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
@ -50,56 +50,7 @@ defmodule BerrypodWeb.Router do
|
|||||||
plug BerrypodWeb.Plugs.LoadTheme
|
plug BerrypodWeb.Plugs.LoadTheme
|
||||||
end
|
end
|
||||||
|
|
||||||
# Public storefront (root level)
|
# ── Routes without the :browser pipeline ──────────────────────────
|
||||||
scope "/", BerrypodWeb do
|
|
||||||
pipe_through [:browser, :shop]
|
|
||||||
|
|
||||||
live_session :coming_soon,
|
|
||||||
layout: {BerrypodWeb.Layouts, :shop},
|
|
||||||
on_mount: [
|
|
||||||
{BerrypodWeb.ThemeHook, :mount_theme}
|
|
||||||
] do
|
|
||||||
live "/coming-soon", Shop.ComingSoon, :index
|
|
||||||
end
|
|
||||||
|
|
||||||
live_session :public_shop,
|
|
||||||
layout: {BerrypodWeb.Layouts, :shop},
|
|
||||||
on_mount: [
|
|
||||||
{BerrypodWeb.UserAuth, :mount_current_scope},
|
|
||||||
{BerrypodWeb.ThemeHook, :mount_theme},
|
|
||||||
{BerrypodWeb.ThemeHook, :require_site_live},
|
|
||||||
{BerrypodWeb.CartHook, :mount_cart},
|
|
||||||
{BerrypodWeb.SearchHook, :mount_search},
|
|
||||||
{BerrypodWeb.AnalyticsHook, :track},
|
|
||||||
{BerrypodWeb.PageEditorHook, :mount_page_editor}
|
|
||||||
] 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
|
|
||||||
end
|
|
||||||
|
|
||||||
# Checkout (POST — creates Stripe session and redirects)
|
|
||||||
post "/checkout", CheckoutController, :create
|
|
||||||
|
|
||||||
# Order lookup (no-JS fallback for contact page form)
|
|
||||||
post "/contact/lookup", OrderLookupController, :lookup
|
|
||||||
|
|
||||||
# Cart form actions (no-JS fallbacks for LiveView cart events)
|
|
||||||
post "/cart/add", CartController, :add
|
|
||||||
post "/cart/remove", CartController, :remove
|
|
||||||
post "/cart/update", CartController, :update_item
|
|
||||||
post "/cart/country", CartController, :update_country
|
|
||||||
end
|
|
||||||
|
|
||||||
# Health check (no auth, no theme loading — for load balancers and uptime monitors)
|
# Health check (no auth, no theme loading — for load balancers and uptime monitors)
|
||||||
scope "/", BerrypodWeb do
|
scope "/", BerrypodWeb do
|
||||||
@ -128,13 +79,6 @@ defmodule BerrypodWeb.Router do
|
|||||||
get "/site.webmanifest", FaviconController, :webmanifest
|
get "/site.webmanifest", FaviconController, :webmanifest
|
||||||
end
|
end
|
||||||
|
|
||||||
# Cart API (session persistence for LiveView)
|
|
||||||
scope "/api", BerrypodWeb do
|
|
||||||
pipe_through [:browser]
|
|
||||||
|
|
||||||
post "/cart", CartController, :update
|
|
||||||
end
|
|
||||||
|
|
||||||
# SVG recoloring (dynamic — can't be pre-generated to disk)
|
# SVG recoloring (dynamic — can't be pre-generated to disk)
|
||||||
scope "/images", BerrypodWeb do
|
scope "/images", BerrypodWeb do
|
||||||
pipe_through :image
|
pipe_through :image
|
||||||
@ -161,40 +105,14 @@ defmodule BerrypodWeb.Router do
|
|||||||
post "/stripe", StripeWebhookController, :handle
|
post "/stripe", StripeWebhookController, :handle
|
||||||
end
|
end
|
||||||
|
|
||||||
# LiveDashboard and ErrorTracker behind admin auth (available in all environments)
|
# ── Routes with the :browser pipeline ─────────────────────────────
|
||||||
scope "/admin" do
|
# All routes below use :browser. The shop scope with its /:slug
|
||||||
pipe_through [:browser, :require_authenticated_user]
|
# catch-all MUST be last so it doesn't intercept other routes.
|
||||||
|
|
||||||
live_dashboard "/dashboard", metrics: BerrypodWeb.Telemetry
|
|
||||||
error_tracker_dashboard("/errors")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Dev-only routes (mailbox preview, error previews)
|
|
||||||
if Application.compile_env(:berrypod, :dev_routes) do
|
|
||||||
scope "/dev" do
|
|
||||||
pipe_through :browser
|
|
||||||
|
|
||||||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
|
||||||
|
|
||||||
# Preview error pages
|
|
||||||
get "/errors/404", BerrypodWeb.ErrorPreviewController, :not_found
|
|
||||||
get "/errors/500", BerrypodWeb.ErrorPreviewController, :server_error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Order lookup verification — sets session email then redirects to /orders
|
|
||||||
scope "/", BerrypodWeb do
|
|
||||||
pipe_through [:browser]
|
|
||||||
|
|
||||||
get "/orders/verify/:token", OrderLookupController, :verify
|
|
||||||
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
|
||||||
end
|
|
||||||
|
|
||||||
# Setup page — minimal live_session, no theme/cart/search hooks
|
# Setup page — minimal live_session, no theme/cart/search hooks
|
||||||
scope "/", BerrypodWeb do
|
scope "/", BerrypodWeb do
|
||||||
pipe_through [:browser]
|
pipe_through [:browser]
|
||||||
|
|
||||||
# Token-based auto-login after setup/recovery
|
|
||||||
get "/setup/login/:token", SetupController, :login
|
get "/setup/login/:token", SetupController, :login
|
||||||
get "/recover/login/:token", SetupController, :recover_login
|
get "/recover/login/:token", SetupController, :recover_login
|
||||||
|
|
||||||
@ -205,7 +123,13 @@ defmodule BerrypodWeb.Router do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
## Authentication routes
|
# LiveDashboard and ErrorTracker behind admin auth (available in all environments)
|
||||||
|
scope "/admin" do
|
||||||
|
pipe_through [:browser, :require_authenticated_user]
|
||||||
|
|
||||||
|
live_dashboard "/dashboard", metrics: BerrypodWeb.Telemetry
|
||||||
|
error_tracker_dashboard("/errors")
|
||||||
|
end
|
||||||
|
|
||||||
# Admin pages with sidebar layout
|
# Admin pages with sidebar layout
|
||||||
scope "/admin", BerrypodWeb do
|
scope "/admin", BerrypodWeb do
|
||||||
@ -269,4 +193,87 @@ defmodule BerrypodWeb.Router do
|
|||||||
post "/users/log-in", UserSessionController, :create
|
post "/users/log-in", UserSessionController, :create
|
||||||
delete "/users/log-out", UserSessionController, :delete
|
delete "/users/log-out", UserSessionController, :delete
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Order lookup verification — sets session email then redirects to /orders
|
||||||
|
scope "/", BerrypodWeb do
|
||||||
|
pipe_through [:browser]
|
||||||
|
|
||||||
|
get "/orders/verify/:token", OrderLookupController, :verify
|
||||||
|
get "/unsubscribe/:token", UnsubscribeController, :unsubscribe
|
||||||
|
end
|
||||||
|
|
||||||
|
# Dev-only routes (mailbox preview, error previews)
|
||||||
|
if Application.compile_env(:berrypod, :dev_routes) do
|
||||||
|
scope "/dev" do
|
||||||
|
pipe_through :browser
|
||||||
|
|
||||||
|
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||||
|
|
||||||
|
# Preview error pages
|
||||||
|
get "/errors/404", BerrypodWeb.ErrorPreviewController, :not_found
|
||||||
|
get "/errors/500", BerrypodWeb.ErrorPreviewController, :server_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cart API (session persistence for LiveView)
|
||||||
|
scope "/api", BerrypodWeb do
|
||||||
|
pipe_through [:browser]
|
||||||
|
|
||||||
|
post "/cart", CartController, :update
|
||||||
|
end
|
||||||
|
|
||||||
|
# Public storefront — MUST be last because /:slug catch-all absorbs
|
||||||
|
# any single-segment path not matched above
|
||||||
|
scope "/", BerrypodWeb do
|
||||||
|
pipe_through [:browser, :shop]
|
||||||
|
|
||||||
|
live_session :coming_soon,
|
||||||
|
layout: {BerrypodWeb.Layouts, :shop},
|
||||||
|
on_mount: [
|
||||||
|
{BerrypodWeb.ThemeHook, :mount_theme}
|
||||||
|
] do
|
||||||
|
live "/coming-soon", Shop.ComingSoon, :index
|
||||||
|
end
|
||||||
|
|
||||||
|
live_session :public_shop,
|
||||||
|
layout: {BerrypodWeb.Layouts, :shop},
|
||||||
|
on_mount: [
|
||||||
|
{BerrypodWeb.UserAuth, :mount_current_scope},
|
||||||
|
{BerrypodWeb.ThemeHook, :mount_theme},
|
||||||
|
{BerrypodWeb.ThemeHook, :require_site_live},
|
||||||
|
{BerrypodWeb.CartHook, :mount_cart},
|
||||||
|
{BerrypodWeb.SearchHook, :mount_search},
|
||||||
|
{BerrypodWeb.AnalyticsHook, :track},
|
||||||
|
{BerrypodWeb.PageEditorHook, :mount_page_editor}
|
||||||
|
] 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
|
||||||
|
|
||||||
|
# Catch-all for custom CMS pages — must be last
|
||||||
|
live "/:slug", Shop.CustomPage, :show
|
||||||
|
end
|
||||||
|
|
||||||
|
# Checkout (POST — creates Stripe session and redirects)
|
||||||
|
post "/checkout", CheckoutController, :create
|
||||||
|
|
||||||
|
# Order lookup (no-JS fallback for contact page form)
|
||||||
|
post "/contact/lookup", OrderLookupController, :lookup
|
||||||
|
|
||||||
|
# Cart form actions (no-JS fallbacks for LiveView cart events)
|
||||||
|
post "/cart/add", CartController, :add
|
||||||
|
post "/cart/remove", CartController, :remove
|
||||||
|
post "/cart/update", CartController, :update_item
|
||||||
|
post "/cart/country", CartController, :update_country
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
123
test/berrypod_web/live/shop/custom_page_test.exs
Normal file
123
test/berrypod_web/live/shop/custom_page_test.exs
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
defmodule BerrypodWeb.Shop.CustomPageTest do
|
||||||
|
use BerrypodWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
import Berrypod.AccountsFixtures
|
||||||
|
|
||||||
|
alias Berrypod.Pages
|
||||||
|
alias Berrypod.Pages.PageCache
|
||||||
|
|
||||||
|
setup do
|
||||||
|
PageCache.invalidate_all()
|
||||||
|
user = user_fixture()
|
||||||
|
{:ok, _} = Berrypod.Settings.set_site_live(true)
|
||||||
|
%{user: user}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "published custom page" do
|
||||||
|
setup do
|
||||||
|
{:ok, _} =
|
||||||
|
Pages.create_custom_page(%{
|
||||||
|
slug: "our-story",
|
||||||
|
title: "Our story",
|
||||||
|
meta_description: "Learn about us"
|
||||||
|
})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders at /:slug", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, "/our-story")
|
||||||
|
assert html =~ "Our story"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets page title", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, "/our-story")
|
||||||
|
assert page_title(view) =~ "Our story"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "unpublished custom page" do
|
||||||
|
setup do
|
||||||
|
{:ok, _} =
|
||||||
|
Pages.create_custom_page(%{
|
||||||
|
slug: "draft-page",
|
||||||
|
title: "Draft page",
|
||||||
|
published: false
|
||||||
|
})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "redirects anonymous users to home", %{conn: conn} do
|
||||||
|
{:error, {:live_redirect, %{to: "/"}}} = live(conn, "/draft-page")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders for admin users", %{conn: conn, user: user} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, _view, html} = live(conn, "/draft-page")
|
||||||
|
assert html =~ "Draft page"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "nonexistent page" do
|
||||||
|
test "redirects to home with flash", %{conn: conn} do
|
||||||
|
{:error, {:live_redirect, %{to: "/"}}} = live(conn, "/does-not-exist")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "system routes are unaffected" do
|
||||||
|
test "/about still routes to Content LiveView", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, "/about")
|
||||||
|
assert html =~ "About"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "/cart still routes to Cart LiveView", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, "/cart")
|
||||||
|
assert html =~ "cart"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "custom page with blocks" do
|
||||||
|
setup do
|
||||||
|
{:ok, _} =
|
||||||
|
Pages.create_custom_page(%{
|
||||||
|
slug: "faq",
|
||||||
|
title: "FAQ",
|
||||||
|
blocks: [
|
||||||
|
%{
|
||||||
|
"id" => "blk_test1",
|
||||||
|
"type" => "hero",
|
||||||
|
"settings" => %{
|
||||||
|
"title" => "Frequently asked questions",
|
||||||
|
"description" => "Answers to common questions",
|
||||||
|
"variant" => "page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "renders blocks", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, "/faq")
|
||||||
|
assert html =~ "Frequently asked questions"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "page editor on custom pages" do
|
||||||
|
setup do
|
||||||
|
{:ok, _} =
|
||||||
|
Pages.create_custom_page(%{slug: "editable", title: "Editable page"})
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "editing works with ?edit=true", %{conn: conn, user: user} do
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
{:ok, view, _html} = live(conn, "/editable?edit=true")
|
||||||
|
assert has_element?(view, ".page-editor-sidebar")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -9,12 +9,13 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "records broken URL on 404", %{conn: conn} do
|
test "records broken URL on 404", %{conn: conn} do
|
||||||
conn = get(conn, "/zz-nonexistent-path")
|
# Multi-segment path — not caught by the /:slug catch-all route
|
||||||
|
conn = get(conn, "/zz/nonexistent-path")
|
||||||
|
|
||||||
assert conn.status in [404, 500]
|
assert conn.status in [404, 500]
|
||||||
|
|
||||||
[broken_url] = Redirects.list_broken_urls()
|
[broken_url] = Redirects.list_broken_urls()
|
||||||
assert broken_url.path == "/zz-nonexistent-path"
|
assert broken_url.path == "/zz/nonexistent-path"
|
||||||
assert broken_url.recent_404_count == 1
|
assert broken_url.recent_404_count == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user