defmodule BerrypodWeb.R do @moduledoc """ Runtime URL paths with ETS caching. Short name for ergonomic use. This module provides functions for generating URLs that respect custom URL slugs and route prefixes configured by the shop owner. ## Usage R.cart() # => "/basket" (if cart is renamed to basket) R.product(123) # => "/p/123" (if products prefix is "p") R.url(R.product(123)) # => "https://shop.com/p/123" ## Page slugs vs route prefixes - **Page slugs** are single-segment paths like `/cart`, `/about`, `/contact` - **Route prefixes** are multi-segment paths like `/products/:id`, `/collections/:slug` Page slugs are stored on individual Page records. Route prefixes are stored in Settings. ## Routing The router uses three catch-all routes (`/`, `/:prefix/:id_or_slug`, `/:slug`) that delegate to `Shop.Page`, which resolves the actual page type at runtime using `page_type_from_slug/1` and `prefix_type_from_segment/1`. See the router's "Dynamic Shop Routes" comment block for the full list of handled pages. To add a new system page type: 1. Add a clause to `default_page_type/1` and `default_slug/1` 2. Add the page module to `@page_modules` in `Shop.Page` """ use GenServer alias Berrypod.{Pages, Settings} @table __MODULE__ # ============================================================================= # Client API - Path functions # ============================================================================= @doc "Returns the home page path (always /)." def home, do: "/" @doc "Returns the cart page path." def cart, do: "/" <> page_slug(:cart) @doc "Returns the about page path." def about, do: "/" <> page_slug(:about) @doc "Returns the contact page path." def contact, do: "/" <> page_slug(:contact) @doc "Returns the search page path." def search, do: "/" <> page_slug(:search) @doc "Returns the delivery page path." def delivery, do: "/" <> page_slug(:delivery) @doc "Returns the privacy page path." def privacy, do: "/" <> page_slug(:privacy) @doc "Returns the terms page path." def terms, do: "/" <> page_slug(:terms) @doc "Returns the checkout success page path." def checkout_success, do: "/" <> page_slug(:checkout_success) @doc "Returns the orders page path." def orders, do: "/" <> page_slug(:orders) @doc """ Returns the path for any page by slug. This is a generic version that works with any page slug, including custom pages. Prefer the specific functions (cart/0, about/0, etc.) when the page is known. """ def page("home"), do: "/" def page("cart"), do: cart() def page("about"), do: about() def page("contact"), do: contact() def page("search"), do: search() def page("delivery"), do: delivery() def page("privacy"), do: privacy() def page("terms"), do: terms() def page("checkout_success"), do: checkout_success() def page("orders"), do: orders() def page(slug) when is_binary(slug), do: "/" <> slug @doc "Returns the path for a specific product." def product(id), do: "/" <> prefix(:products) <> "/" <> to_string(id) @doc "Returns the path for a specific collection." def collection(slug), do: "/" <> prefix(:collections) <> "/" <> slug @doc "Returns the path for a specific order." def order(order_number), do: "/" <> prefix(:orders) <> "/" <> order_number @doc "Returns a full URL (with scheme and host) for the given path." def url(path), do: BerrypodWeb.Endpoint.url() <> path # ============================================================================= # Reverse lookups (for router) # ============================================================================= @doc """ Given a URL slug, returns the page type it maps to. Returns: - `{:page, :cart}` for system pages - `{:custom, page}` for custom pages - `nil` if not found """ def page_type_from_slug(slug) do # First check if it's a custom URL slug for a system page case lookup({:reverse_slug, slug}) do {:ok, page_type} -> {:page, page_type} :not_found -> # Check if it's a default slug for a system page case default_page_type(slug) do nil -> # Check if it's a custom page case Pages.get_published_page_by_effective_url(slug) do nil -> nil page -> {:custom, page} end page_type -> {:page, page_type} end end end @doc """ Given a URL segment, returns the prefix type it maps to. Returns `:products`, `:collections`, `:orders`, or `nil`. """ def prefix_type_from_segment(segment) do case lookup({:reverse_prefix, segment}) do {:ok, type} -> type :not_found -> default_prefix_type(segment) end end @doc """ Returns the current prefix for a given type. Used by the DynamicRoutes plug to check if a custom prefix is set. ## Examples R.prefix(:products) # => "p" (if customised) or "products" (default) R.prefix(:collections) # => "c" (if customised) or "collections" (default) """ def prefix(prefix_type) do key = {:prefix, prefix_type} case lookup(key) do {:ok, prefix} -> prefix :not_found -> default_prefix(prefix_type) end end # ============================================================================= # Cache management # ============================================================================= @doc "Invalidates a specific cache key." def invalidate(key) do GenServer.cast(__MODULE__, {:invalidate, key}) end @doc "Invalidates all cache entries (use after prefix changes)." def invalidate_all do GenServer.cast(__MODULE__, :invalidate_all) end @doc "Invalidates all cache entries synchronously. Use when you need the cache refreshed before proceeding." def invalidate_all_sync do GenServer.call(__MODULE__, :invalidate_all) end @doc "Clears all cache entries without re-warming. Used in tests to reset to defaults." def clear do GenServer.call(__MODULE__, :clear) end @doc "Warms the cache with current values from the database." def warm_cache do GenServer.cast(__MODULE__, :warm_cache) end # ============================================================================= # GenServer # ============================================================================= def start_link(_opts) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @impl GenServer def init(_) do :ets.new(@table, [:named_table, :public, read_concurrency: true]) do_warm_cache() {:ok, %{}} end @impl GenServer def handle_cast({:invalidate, key}, state) do :ets.delete(@table, key) {:noreply, state} end def handle_cast(:invalidate_all, state) do :ets.delete_all_objects(@table) do_warm_cache() {:noreply, state} end def handle_cast(:warm_cache, state) do do_warm_cache() {:noreply, state} end @impl GenServer def handle_call(:invalidate_all, _from, state) do :ets.delete_all_objects(@table) do_warm_cache() {:reply, :ok, state} end def handle_call(:clear, _from, state) do :ets.delete_all_objects(@table) {:reply, :ok, state} end # ============================================================================= # Private - Cache operations # ============================================================================= defp page_slug(page_type) do key = {:slug, page_type} case lookup(key) do {:ok, slug} -> slug :not_found -> default_slug(page_type) end end defp lookup(key) do case :ets.lookup(@table, key) do [{^key, value}] -> {:ok, value} [] -> :not_found end rescue ArgumentError -> :not_found end defp do_warm_cache do # Load page slugs Pages.list_pages_with_custom_urls() |> Enum.each(fn page -> page_type = String.to_existing_atom(page.slug) :ets.insert(@table, {{:slug, page_type}, page.url_slug}) :ets.insert(@table, {{:reverse_slug, page.url_slug}, page_type}) end) # Load route prefixes from settings prefixes = Settings.get_setting("url_prefixes") || %{} Enum.each(prefixes, fn {type_str, prefix} -> type = String.to_existing_atom(type_str) :ets.insert(@table, {{:prefix, type}, prefix}) :ets.insert(@table, {{:reverse_prefix, prefix}, type}) end) rescue # During startup, tables/atoms might not exist yet _ -> :ok end # ============================================================================= # Defaults # ============================================================================= defp default_slug(:cart), do: "cart" defp default_slug(:about), do: "about" defp default_slug(:contact), do: "contact" defp default_slug(:search), do: "search" defp default_slug(:delivery), do: "delivery" defp default_slug(:privacy), do: "privacy" defp default_slug(:terms), do: "terms" defp default_slug(:checkout_success), do: "checkout/success" defp default_slug(:orders), do: "orders" defp default_slug(_), do: nil defp default_prefix(:products), do: "products" defp default_prefix(:collections), do: "collections" defp default_prefix(:orders), do: "orders" defp default_prefix(_), do: nil # Map default slugs back to page types (for router) defp default_page_type("cart"), do: :cart defp default_page_type("about"), do: :about defp default_page_type("contact"), do: :contact defp default_page_type("search"), do: :search defp default_page_type("delivery"), do: :delivery defp default_page_type("privacy"), do: :privacy defp default_page_type("terms"), do: :terms defp default_page_type("orders"), do: :orders defp default_page_type("checkout/success"), do: :checkout_success defp default_page_type(_), do: nil # Map default prefixes back to types defp default_prefix_type("products"), do: :products defp default_prefix_type("collections"), do: :collections defp default_prefix_type("orders"), do: :orders defp default_prefix_type(_), do: nil end