diff --git a/lib/berrypod_web/endpoint.ex b/lib/berrypod_web/endpoint.ex index 856bd27..1aea002 100644 --- a/lib/berrypod_web/endpoint.ex +++ b/lib/berrypod_web/endpoint.ex @@ -13,8 +13,8 @@ defmodule BerrypodWeb.Endpoint do ] socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] + websocket: [connect_info: [:user_agent, session: @session_options]], + longpoll: [connect_info: [:user_agent, session: @session_options]] # In prod, image variants and mockups live on the persistent volume # rather than inside the ephemeral release directory. @@ -50,6 +50,11 @@ defmodule BerrypodWeb.Endpoint do plug Tidewave, allow_remote_access: true end + # Enable sandbox for LiveView tests (allows LV processes to share test DB connection) + if Application.compile_env(:berrypod, :env) == :test do + plug Phoenix.Ecto.SQL.Sandbox + end + # Code reloading can be explicitly enabled under the # :code_reloader configuration of your endpoint. if code_reloading? do @@ -76,5 +81,6 @@ defmodule BerrypodWeb.Endpoint do plug Plug.Head plug Plug.Session, @session_options plug BerrypodWeb.Plugs.Redirects + plug BerrypodWeb.Plugs.DynamicRoutes plug BerrypodWeb.Plugs.BrokenUrlTracker, router: BerrypodWeb.Router end diff --git a/lib/berrypod_web/live_sandbox_hook.ex b/lib/berrypod_web/live_sandbox_hook.ex new file mode 100644 index 0000000..4a3fe4e --- /dev/null +++ b/lib/berrypod_web/live_sandbox_hook.ex @@ -0,0 +1,24 @@ +defmodule BerrypodWeb.LiveSandboxHook do + @moduledoc """ + On-mount hook that allows LiveView processes to use the Ecto SQL sandbox. + Only active in test environment. + """ + + import Phoenix.LiveView + import Phoenix.Component + + def on_mount(:default, _params, _session, socket) do + socket = + assign_new(socket, :phoenix_ecto_sandbox, fn -> + if connected?(socket), do: get_connect_info(socket, :user_agent) + end) + + metadata = socket.assigns.phoenix_ecto_sandbox + + if metadata do + Phoenix.Ecto.SQL.Sandbox.allow(metadata, Ecto.Adapters.SQL.Sandbox) + end + + {:cont, socket} + end +end diff --git a/lib/berrypod_web/plugs/dynamic_routes.ex b/lib/berrypod_web/plugs/dynamic_routes.ex new file mode 100644 index 0000000..b8b998c --- /dev/null +++ b/lib/berrypod_web/plugs/dynamic_routes.ex @@ -0,0 +1,101 @@ +defmodule BerrypodWeb.Plugs.DynamicRoutes do + @moduledoc """ + Plug that enables dynamic route prefixes for products, collections, and orders. + + When a shop owner customises their URL prefixes (e.g., /products → /p), this plug: + + 1. **Rewrites incoming paths** - If the URL uses a custom prefix (/p/123), rewrites + path_info to the canonical form (/products/123) so the router matches. + + 2. **Redirects old prefixes** - If the URL uses the old default prefix when a custom + one is set (/products/123 when prefix is "p"), issues a 301 redirect to /p/123. + + This plug must run AFTER the Redirects plug (which handles trailing slashes and + explicit redirects) but BEFORE the router. + + ## How it works + + The R module maintains an ETS cache of custom prefixes loaded from Settings. + `R.prefix/1` returns the current prefix for a type, and `R.prefix_type_from_segment/1` + performs reverse lookups. + + ## Example + + # Settings: url_prefixes = %{"products" => "p", "collections" => "c"} + + GET /p/123 → Rewrite to /products/123, router matches :product + GET /products/123 → Redirect 301 to /p/123 + GET /c/art → Rewrite to /collections/art, router matches :collection + GET /collections/art → Redirect 301 to /c/art + + When no custom prefix is set, paths pass through unchanged. + """ + + import Plug.Conn + alias BerrypodWeb.R + + @default_prefixes %{ + products: "products", + collections: "collections", + orders: "orders" + } + + def init(opts), do: opts + + def call(conn, _opts) do + case conn.path_info do + [first_segment | rest] when rest != [] -> + handle_prefixed_path(conn, first_segment, rest) + + _ -> + conn + end + end + + defp handle_prefixed_path(conn, segment, rest) do + prefix_type = R.prefix_type_from_segment(segment) + current_prefix = prefix_type && R.prefix(prefix_type) + default_prefix = prefix_type && @default_prefixes[prefix_type] + + cond do + # No prefix type matched - not a dynamic route + is_nil(prefix_type) -> + conn + + # Using the current (possibly custom) prefix - rewrite to default for router + segment == current_prefix and current_prefix != default_prefix -> + rewrite_path(conn, default_prefix, rest, segment) + + # Using default prefix but custom is set - redirect to custom prefix + segment == default_prefix and current_prefix != default_prefix -> + redirect_to_custom(conn, current_prefix, rest) + + # Using the default prefix and no custom set, or already canonical + true -> + conn + end + end + + # Rewrite path_info so the router's static routes match + # Store original path in conn.private for LiveView to use + defp rewrite_path(conn, canonical_prefix, rest, original_prefix) do + conn + |> put_private(:original_prefix, original_prefix) + |> put_private(:original_request_path, conn.request_path) + |> Map.put(:path_info, [canonical_prefix | rest]) + end + + # 301 redirect to the canonical custom prefix + defp redirect_to_custom(conn, custom_prefix, rest) do + path = "/" <> Enum.join([custom_prefix | rest], "/") + location = append_query(path, conn.query_string) + + conn + |> put_resp_header("location", location) + |> send_resp(301, "") + |> halt() + end + + defp append_query(path, ""), do: path + defp append_query(path, qs), do: "#{path}?#{qs}" +end diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex index cfd1109..b8113a5 100644 --- a/lib/berrypod_web/router.ex +++ b/lib/berrypod_web/router.ex @@ -275,21 +275,25 @@ defmodule BerrypodWeb.Router do {BerrypodWeb.PageEditorHook, :mount_page_editor}, {BerrypodWeb.NewsletterHook, :mount_newsletter} ] do - 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 + # ┌─────────────────────────────────────────────────────────────────────┐ + # │ Dynamic Shop Routes │ + # │ │ + # │ All shop pages are served by Shop.Page with runtime route resolution │ + # │ via BerrypodWeb.R. This enables shop owners to customise URLs. │ + # │ │ + # │ Single-segment pages (/:slug): │ + # │ /cart, /about, /contact, /search, /delivery, /privacy, /terms, │ + # │ /orders, /checkout/success, plus custom CMS pages │ + # │ │ + # │ Two-segment pages (/:prefix/:id_or_slug): │ + # │ /products/:id, /collections/:slug, /orders/:number │ + # │ (prefixes are customisable, e.g. /p/:id, /shop/:slug) │ + # │ │ + # │ See: R.default_page_type/1, R.default_prefix_type/1, @page_modules │ + # └─────────────────────────────────────────────────────────────────────┘ - # Catch-all for custom CMS pages — must be last + live "/", Shop.Page, :home + live "/:prefix/:id_or_slug", Shop.Page, :dynamic_prefix live "/:slug", Shop.Page, :custom_page end