diff --git a/lib/berrypod_web/live/shop/custom_page.ex b/lib/berrypod_web/live/shop/custom_page.ex new file mode 100644 index 0000000..a255c42 --- /dev/null +++ b/lib/berrypod_web/live/shop/custom_page.ex @@ -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""" + + """ + 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 diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index bd8c4cb..fc52685 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -50,56 +50,7 @@ defmodule BerrypodWeb.Router do plug BerrypodWeb.Plugs.LoadTheme end - # Public storefront (root level) - 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 + # ── Routes without the :browser pipeline ────────────────────────── # Health check (no auth, no theme loading — for load balancers and uptime monitors) scope "/", BerrypodWeb do @@ -128,13 +79,6 @@ defmodule BerrypodWeb.Router do get "/site.webmanifest", FaviconController, :webmanifest 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) scope "/images", BerrypodWeb do pipe_through :image @@ -161,40 +105,14 @@ defmodule BerrypodWeb.Router do post "/stripe", StripeWebhookController, :handle end - # 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 - - # 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 + # ── Routes with the :browser pipeline ───────────────────────────── + # All routes below use :browser. The shop scope with its /:slug + # catch-all MUST be last so it doesn't intercept other routes. # Setup page — minimal live_session, no theme/cart/search hooks scope "/", BerrypodWeb do pipe_through [:browser] - # Token-based auto-login after setup/recovery get "/setup/login/:token", SetupController, :login get "/recover/login/:token", SetupController, :recover_login @@ -205,7 +123,13 @@ defmodule BerrypodWeb.Router do 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 scope "/admin", BerrypodWeb do @@ -269,4 +193,87 @@ defmodule BerrypodWeb.Router do post "/users/log-in", UserSessionController, :create delete "/users/log-out", UserSessionController, :delete 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 diff --git a/test/berrypod_web/live/shop/custom_page_test.exs b/test/berrypod_web/live/shop/custom_page_test.exs new file mode 100644 index 0000000..33ed05e --- /dev/null +++ b/test/berrypod_web/live/shop/custom_page_test.exs @@ -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 diff --git a/test/berrypod_web/plugs/broken_url_tracker_test.exs b/test/berrypod_web/plugs/broken_url_tracker_test.exs index 7440980..0810f2e 100644 --- a/test/berrypod_web/plugs/broken_url_tracker_test.exs +++ b/test/berrypod_web/plugs/broken_url_tracker_test.exs @@ -9,12 +9,13 @@ defmodule BerrypodWeb.Plugs.BrokenUrlTrackerTest do end 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] [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 end