diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex index 939c49a..5453f09 100644 --- a/lib/berrypod/application.ex +++ b/lib/berrypod/application.ex @@ -36,7 +36,9 @@ defmodule Berrypod.Application do # Start to serve requests BerrypodWeb.Endpoint, # Theme CSS cache - must start after Endpoint for static_path/1 to work - Berrypod.Theme.CSSCache + Berrypod.Theme.CSSCache, + # Page definition cache - loads page block lists into ETS + Berrypod.Pages.PageCache ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/berrypod/pages.ex b/lib/berrypod/pages.ex new file mode 100644 index 0000000..94423dc --- /dev/null +++ b/lib/berrypod/pages.ex @@ -0,0 +1,126 @@ +defmodule Berrypod.Pages do + @moduledoc """ + Context for database-driven page definitions. + + Every page is a flat list of blocks stored as JSON. This module provides + the public API for reading, writing, and loading block data for pages. + + Lookup order: ETS cache -> DB -> defaults. + """ + + alias Berrypod.Repo + alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache} + + import Ecto.Query + + # ── Read ────────────────────────────────────────────────────────── + + @doc """ + Gets a page definition by slug. + + Checks the ETS cache first, falls back to DB, then to built-in defaults. + Returns a map with `:slug`, `:title`, and `:blocks` keys. + """ + def get_page(slug) do + case PageCache.get(slug) do + {:ok, page_data} -> page_data + :miss -> get_page_uncached(slug) + end + end + + @doc """ + Gets a page from the DB, falling back to defaults. Used by PageCache.warm/0. + """ + def get_page_from_db(slug) do + case Repo.one(from p in Page, where: p.slug == ^slug) do + %Page{} = page -> page_to_map(page) + nil -> Defaults.for_slug(slug) + end + end + + defp get_page_uncached(slug) do + page_data = get_page_from_db(slug) + + # Cache for next time (best-effort, ETS might not be up yet) + try do + PageCache.put(slug, page_data) + rescue + ArgumentError -> :ok + end + + page_data + end + + @doc "Lists all pages with their current definitions." + def list_pages do + Page.valid_slugs() + |> Enum.map(&get_page/1) + end + + # ── Write ───────────────────────────────────────────────────────── + + @doc """ + Saves a page's block list. Creates or updates the DB row and invalidates + the cache. + + Returns `{:ok, page}` or `{:error, changeset}`. + """ + def save_page(slug, attrs) do + existing = Repo.one(from p in Page, where: p.slug == ^slug) + + result = + case existing do + nil -> + %Page{} + |> Page.changeset(Map.merge(%{slug: slug}, attrs)) + |> Repo.insert() + + %Page{} = page -> + page + |> Page.changeset(attrs) + |> Repo.update() + end + + case result do + {:ok, page} -> + PageCache.invalidate(slug) + {:ok, page} + + error -> + error + end + end + + @doc """ + Resets a page to its default block list. Deletes the DB row (if any) + and invalidates the cache. + """ + def reset_page(slug) do + case Repo.one(from p in Page, where: p.slug == ^slug) do + nil -> :ok + page -> Repo.delete(page) + end + + PageCache.invalidate(slug) + :ok + end + + # ── Block data loading ──────────────────────────────────────────── + + @doc """ + Runs data loaders for all blocks on a page, returning a merged map + of extra assigns. + + Only blocks with a `data_loader` in the BlockTypes registry trigger + a query. Blocks without data loaders rely on global or page-context assigns. + """ + def load_block_data(blocks, assigns) do + BlockTypes.load_data(blocks, assigns) + end + + # ── Helpers ─────────────────────────────────────────────────────── + + defp page_to_map(%Page{} = page) do + %{slug: page.slug, title: page.title, blocks: page.blocks} + end +end diff --git a/lib/berrypod/pages/block_types.ex b/lib/berrypod/pages/block_types.ex new file mode 100644 index 0000000..f60956f --- /dev/null +++ b/lib/berrypod/pages/block_types.ex @@ -0,0 +1,300 @@ +defmodule Berrypod.Pages.BlockTypes do + @moduledoc """ + Registry of all block types available in the page builder. + + Each block type has a name, icon, allowed pages, settings schema, + and an optional data loader that fetches extra data when the block + is present on a page. + """ + + alias Berrypod.Products + alias Berrypod.Theme.PreviewData + + @block_types %{ + # ── Portable blocks (work on any page) ────────────────────────── + + "hero" => %{ + name: "Hero banner", + icon: "hero-megaphone", + allowed_on: :all, + settings_schema: [ + %{key: "title", label: "Title", type: :text, default: ""}, + %{key: "description", label: "Description", type: :textarea, default: ""}, + %{key: "cta_text", label: "Button text", type: :text, default: ""}, + %{key: "cta_href", label: "Button link", type: :text, default: ""}, + %{ + key: "variant", + label: "Style", + type: :select, + options: ~w(default page sunken error), + default: "default" + } + ] + }, + "featured_products" => %{ + name: "Featured products", + icon: "hero-star", + allowed_on: :all, + settings_schema: [ + %{key: "title", label: "Title", type: :text, default: "Featured products"}, + %{key: "product_count", label: "Number of products", type: :number, default: 8} + ], + data_loader: :load_featured_products + }, + "image_text" => %{ + name: "Image + text", + icon: "hero-photo", + allowed_on: :all, + settings_schema: [ + %{key: "title", label: "Title", type: :text, default: ""}, + %{key: "description", label: "Description", type: :textarea, default: ""}, + %{key: "image_url", label: "Image URL", type: :text, default: ""}, + %{key: "link_text", label: "Link text", type: :text, default: ""}, + %{key: "link_href", label: "Link URL", type: :text, default: ""} + ] + }, + "category_nav" => %{ + name: "Category navigation", + icon: "hero-squares-2x2", + allowed_on: :all, + settings_schema: [] + }, + "newsletter_card" => %{ + name: "Newsletter signup", + icon: "hero-envelope", + allowed_on: :all, + settings_schema: [] + }, + "social_links_card" => %{ + name: "Social links", + icon: "hero-share", + allowed_on: :all, + settings_schema: [] + }, + "info_card" => %{ + name: "Info card", + icon: "hero-information-circle", + allowed_on: :all, + settings_schema: [ + %{key: "title", label: "Title", type: :text, default: ""}, + %{key: "items", label: "Items", type: :json, default: []} + ] + }, + "trust_badges" => %{ + name: "Trust badges", + icon: "hero-shield-check", + allowed_on: :all, + settings_schema: [] + }, + "reviews_section" => %{ + name: "Customer reviews", + icon: "hero-chat-bubble-left-right", + allowed_on: :all, + settings_schema: [], + data_loader: :load_reviews + }, + + # ── PDP blocks ────────────────────────────────────────────────── + + "product_hero" => %{ + name: "Product hero", + icon: "hero-cube", + allowed_on: ["pdp"], + settings_schema: [] + }, + "breadcrumb" => %{ + name: "Breadcrumb", + icon: "hero-chevron-right", + allowed_on: ["pdp"], + settings_schema: [] + }, + "product_details" => %{ + name: "Product details", + icon: "hero-document-text", + allowed_on: ["pdp"], + settings_schema: [] + }, + "related_products" => %{ + name: "Related products", + icon: "hero-squares-plus", + allowed_on: ["pdp"], + settings_schema: [], + data_loader: :load_related_products + }, + + # ── Collection blocks ─────────────────────────────────────────── + + "collection_header" => %{ + name: "Collection header", + icon: "hero-tag", + allowed_on: ["collection"], + settings_schema: [] + }, + "filter_bar" => %{ + name: "Filter bar", + icon: "hero-funnel", + allowed_on: ["collection"], + settings_schema: [] + }, + "product_grid" => %{ + name: "Product grid", + icon: "hero-squares-2x2", + allowed_on: ["collection"], + settings_schema: [] + }, + + # ── Cart blocks ───────────────────────────────────────────────── + + "cart_items" => %{ + name: "Cart items", + icon: "hero-shopping-cart", + allowed_on: ["cart"], + settings_schema: [] + }, + "order_summary" => %{ + name: "Order summary", + icon: "hero-calculator", + allowed_on: ["cart"], + settings_schema: [] + }, + + # ── Contact blocks ────────────────────────────────────────────── + + "contact_form" => %{ + name: "Contact form", + icon: "hero-envelope", + allowed_on: ["contact"], + settings_schema: [ + %{key: "email", label: "Email", type: :text, default: "hello@example.com"} + ] + }, + "order_tracking_card" => %{ + name: "Order tracking", + icon: "hero-truck", + allowed_on: ["contact"], + settings_schema: [] + }, + + # ── Content blocks ────────────────────────────────────────────── + + "content_body" => %{ + name: "Page content", + icon: "hero-document-text", + allowed_on: ["about", "delivery", "privacy", "terms"], + settings_schema: [ + %{key: "image_src", label: "Image", type: :text, default: ""}, + %{key: "image_alt", label: "Image alt text", type: :text, default: ""} + ] + }, + + # ── Checkout success ──────────────────────────────────────────── + + "checkout_result" => %{ + name: "Checkout result", + icon: "hero-check-circle", + allowed_on: ["checkout_success"], + settings_schema: [] + }, + + # ── Orders ────────────────────────────────────────────────────── + + "order_card" => %{ + name: "Order cards", + icon: "hero-clipboard-document-list", + allowed_on: ["orders"], + settings_schema: [] + }, + + # ── Order detail ──────────────────────────────────────────────── + + "order_detail_card" => %{ + name: "Order detail", + icon: "hero-clipboard-document", + allowed_on: ["order_detail"], + settings_schema: [] + }, + + # ── Search ────────────────────────────────────────────────────── + + "search_results" => %{ + name: "Search results", + icon: "hero-magnifying-glass", + allowed_on: ["search"], + settings_schema: [] + } + } + + @doc "Returns the full block type registry." + def all, do: @block_types + + @doc "Returns the definition for a single block type, or nil." + def get(type), do: Map.get(@block_types, type) + + @doc "Returns block types allowed on the given page slug." + def allowed_for(page_slug) do + @block_types + |> Enum.filter(fn {_type, def} -> + def.allowed_on == :all or page_slug in def.allowed_on + end) + |> Map.new() + end + + @doc """ + Runs data loaders for a list of blocks, merging results into a single map. + + Each block type can declare a `data_loader` — a function that returns + extra assigns needed by that block. Only fires when the block is on the page. + """ + def load_data(blocks, assigns) do + blocks + |> Enum.reduce(%{}, fn block, acc -> + case get(block["type"]) do + %{data_loader: loader} -> + Map.merge(acc, run_loader(loader, assigns, block["settings"] || %{})) + + _ -> + acc + end + end) + end + + # ── Data loaders ──────────────────────────────────────────────── + + defp run_loader(:load_featured_products, assigns, settings) do + limit = settings["product_count"] || 8 + + products = + if assigns[:mode] == :preview do + PreviewData.products() |> Enum.take(limit) + else + Products.list_visible_products(limit: limit) + end + + %{products: products} + end + + defp run_loader(:load_related_products, assigns, _settings) do + products = + if assigns[:mode] == :preview do + PreviewData.products() |> Enum.take(4) + else + case assigns[:product] do + %{category: cat, id: id} when not is_nil(cat) -> + Products.list_visible_products(category: cat, limit: 4, exclude: id) + + _ -> + [] + end + end + + %{related_products: products} + end + + defp run_loader(:load_reviews, _assigns, _settings) do + %{ + reviews: PreviewData.reviews(), + average_rating: 5, + total_count: 24 + } + end +end diff --git a/lib/berrypod/pages/defaults.ex b/lib/berrypod/pages/defaults.ex new file mode 100644 index 0000000..fe3caca --- /dev/null +++ b/lib/berrypod/pages/defaults.ex @@ -0,0 +1,212 @@ +defmodule Berrypod.Pages.Defaults do + @moduledoc """ + Default block definitions for all pages. + + These match the current static templates exactly. When a page has never + been customised, `Pages.get_page/1` returns these defaults. First edit + saves to the DB and overrides them. + """ + + @doc "Returns the default page definition for the given slug." + def for_slug(slug), do: %{slug: slug, title: title(slug), blocks: blocks(slug)} + + @doc "Returns default definitions for all pages." + def all do + Berrypod.Pages.Page.valid_slugs() + |> Enum.map(&for_slug/1) + end + + # ── Page titles ───────────────────────────────────────────────── + + defp title("home"), do: "Home page" + defp title("about"), do: "About" + defp title("delivery"), do: "Delivery" + defp title("privacy"), do: "Privacy policy" + defp title("terms"), do: "Terms & conditions" + defp title("contact"), do: "Contact" + defp title("collection"), do: "Collection" + defp title("pdp"), do: "Product page" + defp title("cart"), do: "Cart" + defp title("search"), do: "Search" + defp title("checkout_success"), do: "Checkout success" + defp title("orders"), do: "Orders" + defp title("order_detail"), do: "Order detail" + defp title("error"), do: "Error" + + # ── Block lists ───────────────────────────────────────────────── + + defp blocks("home") do + [ + block("hero", %{ + "title" => "Original designs, printed on demand", + "description" => + "Welcome to the Berrypod demo store. This is where your hero text goes \u2013 something short and punchy about what makes your shop worth a browse.", + "cta_text" => "Shop the collection", + "cta_href" => "/collections/all", + "variant" => "default" + }), + block("category_nav"), + block("featured_products", %{ + "title" => "Featured products", + "product_count" => 8 + }), + block("image_text", %{ + "title" => "Made with passion, printed with care", + "description" => + "This is an example content section. Use it to share your story, highlight what makes your products special, or link to your about page.", + "image_url" => "/mockups/mountain-sunrise-print-3-800.webp", + "link_text" => "Learn more about the studio \u2192", + "link_href" => "/about" + }) + ] + end + + defp blocks("about") do + [ + block("hero", %{ + "title" => "About the studio", + "description" => "", + "variant" => "sunken" + }), + block("content_body", %{ + "image_src" => "/mockups/night-sky-blanket-3", + "image_alt" => "Night sky blanket" + }) + ] + end + + defp blocks("delivery") do + [ + block("hero", %{ + "title" => "Delivery information", + "description" => "", + "variant" => "page" + }), + block("content_body") + ] + end + + defp blocks("privacy") do + [ + block("hero", %{ + "title" => "Privacy policy", + "description" => "", + "variant" => "page" + }), + block("content_body") + ] + end + + defp blocks("terms") do + [ + block("hero", %{ + "title" => "Terms & conditions", + "description" => "", + "variant" => "page" + }), + block("content_body") + ] + end + + defp blocks("contact") do + [ + block("hero", %{ + "title" => "Get in touch", + "description" => + "Sample contact page for the demo store. Add your own message here \u2013 something friendly about how customers can reach you.", + "variant" => "page" + }), + block("contact_form", %{"email" => "hello@example.com"}), + block("order_tracking_card"), + block("info_card", %{ + "title" => "Handy to know", + "items" => [ + %{"label" => "Printing", "value" => "Example: 2-5 business days"}, + %{"label" => "Delivery", "value" => "Example: 3-7 business days after printing"}, + %{"label" => "Issues", "value" => "Example: Reprints for any defects"} + ] + }), + block("newsletter_card"), + block("social_links_card") + ] + end + + defp blocks("collection") do + [ + block("collection_header"), + block("filter_bar"), + block("product_grid") + ] + end + + defp blocks("pdp") do + [ + block("breadcrumb"), + block("product_hero"), + block("trust_badges"), + block("product_details"), + block("reviews_section"), + block("related_products") + ] + end + + defp blocks("cart") do + [ + block("cart_items"), + block("order_summary") + ] + end + + defp blocks("search") do + [ + block("search_results") + ] + end + + defp blocks("checkout_success") do + [ + block("checkout_result") + ] + end + + defp blocks("orders") do + [ + block("order_card") + ] + end + + defp blocks("order_detail") do + [ + block("order_detail_card") + ] + end + + defp blocks("error") do + [ + block("hero", %{ + "variant" => "error", + "cta_text" => "Go to Homepage", + "cta_href" => "/" + }), + block("featured_products", %{ + "title" => "Featured products", + "product_count" => 4 + }) + ] + end + + # ── Helpers ───────────────────────────────────────────────────── + + defp block(type, settings \\ %{}) do + %{ + "id" => generate_block_id(), + "type" => type, + "settings" => settings + } + end + + @doc "Generates a unique block ID." + def generate_block_id do + "blk_" <> (:crypto.strong_rand_bytes(8) |> Base.url_encode64(padding: false)) + end +end diff --git a/lib/berrypod/pages/page.ex b/lib/berrypod/pages/page.ex new file mode 100644 index 0000000..ccfcc01 --- /dev/null +++ b/lib/berrypod/pages/page.ex @@ -0,0 +1,31 @@ +defmodule Berrypod.Pages.Page do + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @valid_slugs ~w( + home about delivery privacy terms contact + collection pdp cart search + checkout_success orders order_detail error + ) + + schema "pages" do + field :slug, :string + field :title, :string + field :blocks, {:array, :map}, default: [] + + timestamps(type: :utc_datetime) + end + + def changeset(page, attrs) do + page + |> cast(attrs, [:slug, :title, :blocks]) + |> validate_required([:slug, :title, :blocks]) + |> validate_inclusion(:slug, @valid_slugs) + |> unique_constraint(:slug) + end + + def valid_slugs, do: @valid_slugs +end diff --git a/lib/berrypod/pages/page_cache.ex b/lib/berrypod/pages/page_cache.ex new file mode 100644 index 0000000..c6fc22e --- /dev/null +++ b/lib/berrypod/pages/page_cache.ex @@ -0,0 +1,86 @@ +defmodule Berrypod.Pages.PageCache do + @moduledoc """ + GenServer that maintains an ETS table for caching page definitions. + + Same pattern as `Berrypod.Theme.CSSCache`. Pages are cached by slug + for O(1) lookups. Invalidated on save, warmed on startup. + """ + + use GenServer + + @table_name :page_cache + + ## Client API + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc "Gets a cached page by slug. Returns `{:ok, page_data}` or `:miss`." + def get(slug) do + case :ets.lookup(@table_name, slug) do + [{^slug, page_data}] -> {:ok, page_data} + [] -> :miss + end + end + + @doc "Caches a page definition by slug." + def put(slug, page_data) do + :ets.insert(@table_name, {slug, page_data}) + :ok + end + + @doc "Invalidates a single cached page." + def invalidate(slug) do + :ets.delete(@table_name, slug) + :ok + rescue + ArgumentError -> :ok + end + + @doc "Invalidates all cached pages." + def invalidate_all do + :ets.delete_all_objects(@table_name) + :ok + rescue + ArgumentError -> :ok + end + + @doc "Warms the cache by loading all pages from the DB." + def warm do + alias Berrypod.Pages + + for slug <- Berrypod.Pages.Page.valid_slugs() do + page = Pages.get_page_from_db(slug) + put(slug, page) + end + + :ok + end + + ## Server callbacks + + @impl true + def init(_opts) do + :ets.new(@table_name, [ + :set, + :public, + :named_table, + read_concurrency: true, + write_concurrency: false + ]) + + {:ok, %{}, {:continue, :warm}} + end + + @impl true + def handle_continue(:warm, state) do + try do + warm() + rescue + _ -> :ok + end + + {:noreply, state} + end +end diff --git a/priv/repo/migrations/20260226100000_create_pages.exs b/priv/repo/migrations/20260226100000_create_pages.exs new file mode 100644 index 0000000..4c37c16 --- /dev/null +++ b/priv/repo/migrations/20260226100000_create_pages.exs @@ -0,0 +1,16 @@ +defmodule Berrypod.Repo.Migrations.CreatePages do + use Ecto.Migration + + def change do + create table(:pages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :slug, :string, null: false + add :title, :string, null: false + add :blocks, :map, default: "[]" + + timestamps(type: :utc_datetime) + end + + create unique_index(:pages, [:slug]) + end +end diff --git a/test/berrypod/pages_test.exs b/test/berrypod/pages_test.exs new file mode 100644 index 0000000..35a78df --- /dev/null +++ b/test/berrypod/pages_test.exs @@ -0,0 +1,341 @@ +defmodule Berrypod.PagesTest do + use Berrypod.DataCase, async: true + + alias Berrypod.Pages + alias Berrypod.Pages.{Page, BlockTypes, Defaults, PageCache} + + describe "get_page/1" do + test "returns defaults when nothing in DB" do + page = Pages.get_page("home") + + assert page.slug == "home" + assert page.title == "Home page" + assert is_list(page.blocks) + assert length(page.blocks) == 4 + + types = Enum.map(page.blocks, & &1["type"]) + assert types == ["hero", "category_nav", "featured_products", "image_text"] + end + + test "returns DB version when page has been saved" do + {:ok, _} = + Pages.save_page("home", %{ + title: "Custom home", + blocks: [%{"id" => "blk_test", "type" => "hero", "settings" => %{}}] + }) + + page = Pages.get_page("home") + + assert page.slug == "home" + assert page.title == "Custom home" + assert length(page.blocks) == 1 + end + + test "each block has an id, type, and settings" do + page = Pages.get_page("contact") + + for block <- page.blocks do + assert is_binary(block["id"]), "block missing id" + assert is_binary(block["type"]), "block missing type" + assert is_map(block["settings"]), "block missing settings" + end + end + end + + describe "list_pages/0" do + test "returns all 14 pages" do + pages = Pages.list_pages() + + assert length(pages) == 14 + slugs = Enum.map(pages, & &1.slug) + assert "home" in slugs + assert "pdp" in slugs + assert "error" in slugs + end + end + + describe "save_page/2" do + test "creates a new page in the DB" do + blocks = [%{"id" => "blk_1", "type" => "hero", "settings" => %{"title" => "Hello"}}] + + assert {:ok, page} = Pages.save_page("about", %{title: "About us", blocks: blocks}) + + assert page.slug == "about" + assert page.title == "About us" + assert length(page.blocks) == 1 + end + + test "updates an existing page" do + {:ok, _} = Pages.save_page("about", %{title: "About v1", blocks: []}) + {:ok, page} = Pages.save_page("about", %{title: "About v2", blocks: []}) + + assert page.title == "About v2" + assert Repo.aggregate(from(p in Page, where: p.slug == "about"), :count) == 1 + end + + test "invalidates cache on save" do + # Prime the cache + _page = Pages.get_page("home") + + # Save a new version + {:ok, _} = Pages.save_page("home", %{title: "New home", blocks: []}) + + # Should return the new version + page = Pages.get_page("home") + assert page.title == "New home" + end + + test "rejects invalid slug" do + assert {:error, changeset} = + Pages.save_page("nonexistent", %{title: "Bad", blocks: []}) + + assert errors_on(changeset).slug + end + end + + describe "reset_page/1" do + test "restores defaults after customisation" do + {:ok, _} = + Pages.save_page("home", %{ + title: "Custom", + blocks: [%{"id" => "blk_1", "type" => "hero", "settings" => %{}}] + }) + + assert :ok = Pages.reset_page("home") + + page = Pages.get_page("home") + assert page.title == "Home page" + assert length(page.blocks) == 4 + end + + test "is a no-op for a page that was never saved" do + assert :ok = Pages.reset_page("cart") + end + end + + describe "load_block_data/2" do + test "returns empty map when no blocks have data loaders" do + blocks = [ + %{"type" => "hero", "settings" => %{}}, + %{"type" => "category_nav", "settings" => %{}} + ] + + assert Pages.load_block_data(blocks, %{}) == %{} + end + + test "loads review data when reviews_section block is present" do + blocks = [%{"type" => "reviews_section", "settings" => %{}}] + data = Pages.load_block_data(blocks, %{}) + + assert is_list(data.reviews) + assert data.average_rating == 5 + assert data.total_count == 24 + end + + test "loads featured products with configurable count" do + blocks = [%{"type" => "featured_products", "settings" => %{"product_count" => 4}}] + data = Pages.load_block_data(blocks, %{mode: :preview}) + + assert is_list(data.products) + end + + test "loads related products in preview mode" do + blocks = [%{"type" => "related_products", "settings" => %{}}] + data = Pages.load_block_data(blocks, %{mode: :preview}) + + assert is_list(data.related_products) + end + + test "loads related products from DB with product context" do + blocks = [%{"type" => "related_products", "settings" => %{}}] + data = Pages.load_block_data(blocks, %{product: %{category: "Prints", id: "fake-id"}}) + + assert is_list(data.related_products) + end + + test "merges data from multiple loaders" do + blocks = [ + %{"type" => "featured_products", "settings" => %{}}, + %{"type" => "reviews_section", "settings" => %{}} + ] + + data = Pages.load_block_data(blocks, %{mode: :preview}) + + assert Map.has_key?(data, :products) + assert Map.has_key?(data, :reviews) + end + end + + describe "BlockTypes" do + test "all/0 returns all block types" do + types = BlockTypes.all() + + assert is_map(types) + assert map_size(types) > 20 + assert Map.has_key?(types, "hero") + assert Map.has_key?(types, "product_hero") + assert Map.has_key?(types, "search_results") + end + + test "get/1 returns a block type definition" do + hero = BlockTypes.get("hero") + + assert hero.name == "Hero banner" + assert hero.icon == "hero-megaphone" + assert hero.allowed_on == :all + assert is_list(hero.settings_schema) + end + + test "get/1 returns nil for unknown type" do + assert BlockTypes.get("nonexistent") == nil + end + + test "allowed_for/1 returns portable + page-specific blocks" do + pdp_types = BlockTypes.allowed_for("pdp") + + # Portable blocks are included + assert Map.has_key?(pdp_types, "hero") + assert Map.has_key?(pdp_types, "featured_products") + + # PDP-specific blocks are included + assert Map.has_key?(pdp_types, "product_hero") + assert Map.has_key?(pdp_types, "breadcrumb") + + # Other page-specific blocks are excluded + refute Map.has_key?(pdp_types, "cart_items") + refute Map.has_key?(pdp_types, "contact_form") + end + + test "allowed_for/1 includes only relevant page-specific blocks" do + cart_types = BlockTypes.allowed_for("cart") + + assert Map.has_key?(cart_types, "cart_items") + assert Map.has_key?(cart_types, "order_summary") + refute Map.has_key?(cart_types, "product_hero") + end + + test "every block type has required fields" do + for {type, def} <- BlockTypes.all() do + assert is_binary(def.name), "#{type} missing name" + assert is_binary(def.icon), "#{type} missing icon" + assert def.allowed_on == :all or is_list(def.allowed_on), "#{type} bad allowed_on" + assert is_list(def.settings_schema), "#{type} missing settings_schema" + end + end + + test "settings_schema entries have required fields" do + for {type, def} <- BlockTypes.all(), setting <- def.settings_schema do + assert is_binary(setting.key), "#{type} setting missing key" + assert is_binary(setting.label), "#{type} setting missing label" + assert is_atom(setting.type), "#{type} setting missing type" + assert Map.has_key?(setting, :default), "#{type} setting #{setting.key} missing default" + end + end + end + + describe "Defaults" do + test "all/0 returns 14 page definitions" do + all = Defaults.all() + assert length(all) == 14 + end + + test "every default page has blocks matching registered types" do + for page <- Defaults.all() do + assert is_binary(page.slug), "page missing slug" + assert is_binary(page.title), "page missing title" + assert is_list(page.blocks), "#{page.slug} blocks is not a list" + + for block <- page.blocks do + assert BlockTypes.get(block["type"]), + "#{page.slug} has unknown block type: #{block["type"]}" + end + end + end + + test "every default block type is allowed on its page" do + for page <- Defaults.all() do + allowed = BlockTypes.allowed_for(page.slug) + + for block <- page.blocks do + assert Map.has_key?(allowed, block["type"]), + "#{block["type"]} not allowed on #{page.slug}" + end + end + end + + test "block IDs are unique within each page" do + for page <- Defaults.all() do + ids = Enum.map(page.blocks, & &1["id"]) + assert ids == Enum.uniq(ids), "duplicate block IDs on #{page.slug}" + end + end + + test "home page has the expected blocks" do + page = Defaults.for_slug("home") + + types = Enum.map(page.blocks, & &1["type"]) + assert types == ["hero", "category_nav", "featured_products", "image_text"] + end + + test "pdp page has the expected blocks" do + page = Defaults.for_slug("pdp") + + types = Enum.map(page.blocks, & &1["type"]) + + assert types == [ + "breadcrumb", + "product_hero", + "trust_badges", + "product_details", + "reviews_section", + "related_products" + ] + end + + test "contact page has the expected blocks" do + page = Defaults.for_slug("contact") + + types = Enum.map(page.blocks, & &1["type"]) + + assert types == [ + "hero", + "contact_form", + "order_tracking_card", + "info_card", + "newsletter_card", + "social_links_card" + ] + end + + test "generate_block_id/0 produces unique prefixed IDs" do + id1 = Defaults.generate_block_id() + id2 = Defaults.generate_block_id() + + assert String.starts_with?(id1, "blk_") + assert String.starts_with?(id2, "blk_") + assert id1 != id2 + end + end + + describe "PageCache" do + test "get/1 returns :miss for uncached slug" do + assert :miss = PageCache.get("nonexistent_slug_#{System.unique_integer()}") + end + + test "put/2 and get/1 round-trip" do + slug = "test_cache_#{System.unique_integer()}" + page_data = %{slug: slug, title: "Test", blocks: []} + + assert :ok = PageCache.put(slug, page_data) + assert {:ok, ^page_data} = PageCache.get(slug) + end + + test "invalidate/1 removes cached entry" do + slug = "test_invalidate_#{System.unique_integer()}" + PageCache.put(slug, %{slug: slug, title: "Test", blocks: []}) + + assert :ok = PageCache.invalidate(slug) + assert :miss = PageCache.get(slug) + end + end +end