diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index df00da7..3e044b3 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -22,6 +22,8 @@ min-height: 100vh; min-height: 100dvh; grid-column: 2; + min-width: 0; + overflow-x: hidden; } .admin-sidebar-wrapper { @@ -249,6 +251,11 @@ /* ── Tables ── */ +.admin-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + .admin-table { width: 100%; border-collapse: collapse; @@ -2125,12 +2132,15 @@ min-height: 400px; } +.media-grid-wrapper { + flex: 1; + min-width: 0; +} + .media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 0.75rem; - flex: 1; - min-width: 0; align-content: start; } @@ -2469,4 +2479,52 @@ } } +/* ── Pagination ── */ + +.admin-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--t-surface-sunken, var(--color-base-200)); +} + +.admin-pagination-showing { + font-size: 0.875rem; + color: color-mix(in oklch, var(--t-text-primary) 55%, transparent); + white-space: nowrap; +} + +.admin-pagination-buttons { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; +} + +.admin-pagination-ellipsis { + padding-inline: 0.25rem; + font-size: 0.875rem; + color: color-mix(in oklch, var(--t-text-primary) 35%, transparent); +} + +.admin-btn-disabled { + opacity: 0.35; + pointer-events: none; +} + +@media (max-width: 35.99em) { + .admin-pagination { + justify-content: center; + } + + .admin-pagination-showing { + width: 100%; + text-align: center; + } +} + } /* @layer admin */ diff --git a/assets/css/shop/components.css b/assets/css/shop/components.css index ff95c87..1af420c 100644 --- a/assets/css/shop/components.css +++ b/assets/css/shop/components.css @@ -467,6 +467,102 @@ font-size: var(--t-text-small); } + /* ── Shop pagination ── */ + + .shop-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 2.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--t-border); + } + + .shop-pagination-showing { + font-size: var(--t-text-small); + color: var(--t-text-secondary); + } + + .shop-pagination-buttons { + display: flex; + align-items: center; + gap: 0.25rem; + flex-wrap: wrap; + } + + .shop-pagination-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + height: 2.25rem; + padding-inline: 0.5rem; + font-size: var(--t-text-small); + color: var(--t-text-primary); + background: transparent; + border: 1px solid var(--t-border); + border-radius: var(--t-radius-button, 0.375rem); + text-decoration: none; + transition: background-color 0.15s, border-color 0.15s; + + &:hover { + background: var(--t-surface-sunken, var(--t-surface-alt)); + } + } + + .shop-pagination-btn-active { + background: var(--t-accent); + color: var(--t-text-inverse, #fff); + border-color: var(--t-accent); + pointer-events: none; + + &:hover { + background: var(--t-accent); + } + } + + .shop-pagination-btn-disabled { + opacity: 0.4; + pointer-events: none; + } + + .shop-pagination-ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + height: 2.25rem; + font-size: var(--t-text-small); + color: var(--t-text-secondary); + } + + @media (max-width: 35.99em) { + .shop-pagination { + justify-content: center; + } + + .shop-pagination-showing { + width: 100%; + text-align: center; + } + + .shop-pagination-buttons { + justify-content: center; + } + + .shop-pagination-btn { + min-width: 2rem; + height: 2rem; + font-size: 0.8125rem; + } + + .shop-pagination-ellipsis { + min-width: 1.5rem; + } + } + /* ── Search page ── */ .search-page-form { diff --git a/lib/berrypod/media.ex b/lib/berrypod/media.ex index d0f70eb..a6fe5de 100644 --- a/lib/berrypod/media.ex +++ b/lib/berrypod/media.ex @@ -213,6 +213,20 @@ defmodule Berrypod.Media do |> Repo.all() end + @doc """ + Like `list_images/1` but returns a `%Pagination{}` struct. + """ + def list_images_paginated(opts \\ []) do + from(i in ImageSchema, + select: %{i | data: nil}, + order_by: [desc: i.inserted_at] + ) + |> filter_by_type(opts[:type]) + |> filter_by_search(opts[:search]) + |> filter_by_tag(opts[:tag]) + |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 36) + end + defp filter_by_type(query, nil), do: query defp filter_by_type(query, ""), do: query defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type) diff --git a/lib/berrypod/newsletter.ex b/lib/berrypod/newsletter.ex index 7228d5a..7870543 100644 --- a/lib/berrypod/newsletter.ex +++ b/lib/berrypod/newsletter.ex @@ -162,6 +162,29 @@ defmodule Berrypod.Newsletter do Repo.all(query) end + @doc """ + Like `list_subscribers/1` but returns a `%Pagination{}` struct. + """ + def list_subscribers_paginated(opts \\ []) do + query = from(s in Subscriber, order_by: [desc: s.inserted_at]) + + query = + case Keyword.get(opts, :status) do + nil -> query + "all" -> query + status -> from(s in query, where: s.status == ^status) + end + + query = + case Keyword.get(opts, :search) do + nil -> query + "" -> query + term -> from(s in query, where: like(s.email, ^"%#{term}%")) + end + + Berrypod.Pagination.paginate(query, page: opts[:page], per_page: opts[:per_page] || 25) + end + @doc "Returns subscriber counts grouped by status." def count_subscribers_by_status do from(s in Subscriber, @@ -228,6 +251,11 @@ defmodule Berrypod.Newsletter do |> Repo.all() end + def list_campaigns_paginated(opts \\ []) do + from(c in Campaign, order_by: [desc: c.inserted_at]) + |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25) + end + def get_campaign!(id), do: Repo.get!(Campaign, id) def create_campaign(attrs) do diff --git a/lib/berrypod/newsletter/notifier.ex b/lib/berrypod/newsletter/notifier.ex index f8ec066..83289b7 100644 --- a/lib/berrypod/newsletter/notifier.ex +++ b/lib/berrypod/newsletter/notifier.ex @@ -146,6 +146,7 @@ defmodule Berrypod.Newsletter.Notifier do @doc false def wrap_html(shop_name, content, unsubscribe_url \\ nil) do logo_html = build_logo_html(shop_name) + footer = if unsubscribe_url do """ @@ -244,7 +245,10 @@ defmodule Berrypod.Newsletter.Notifier do defp has_favicon_icon? do require Ecto.Query - Berrypod.Repo.exists?(Ecto.Query.from(i in Berrypod.Media.Image, where: i.image_type == "icon")) + + Berrypod.Repo.exists?( + Ecto.Query.from(i in Berrypod.Media.Image, where: i.image_type == "icon") + ) end # Turns bare URLs in escaped text into clickable links. diff --git a/lib/berrypod/orders.ex b/lib/berrypod/orders.ex index 9a7cbae..eeee672 100644 --- a/lib/berrypod/orders.ex +++ b/lib/berrypod/orders.ex @@ -35,6 +35,17 @@ defmodule Berrypod.Orders do |> Repo.all() end + @doc """ + Like `list_orders/1` but returns a `%Pagination{}` struct. + """ + def list_orders_paginated(opts \\ []) do + Order + |> maybe_filter_status(opts[:status]) + |> order_by([o], desc: o.inserted_at) + |> preload(:items) + |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25) + end + defp maybe_filter_status(query, nil), do: query defp maybe_filter_status(query, "all"), do: query defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status) diff --git a/lib/berrypod/pagination.ex b/lib/berrypod/pagination.ex new file mode 100644 index 0000000..76d9758 --- /dev/null +++ b/lib/berrypod/pagination.ex @@ -0,0 +1,119 @@ +defmodule Berrypod.Pagination do + @moduledoc """ + Offset-based pagination for Ecto queries. + + Returns a `%Berrypod.Pagination{}` struct with items, page metadata, + and total count. Context modules pipe their filtered/ordered queries + into `paginate/2` instead of `Repo.all/0`. + """ + + import Ecto.Query + + alias Berrypod.Repo + + @enforce_keys [:items, :page, :per_page, :total_count, :total_pages] + defstruct [:items, :page, :per_page, :total_count, :total_pages] + + @type t :: %__MODULE__{ + items: [any()], + page: pos_integer(), + per_page: pos_integer(), + total_count: non_neg_integer(), + total_pages: non_neg_integer() + } + + @doc """ + Paginates an Ecto query. + + Options: + * `:page` - current page (default 1, clamped to valid range) + * `:per_page` - items per page (default 25) + """ + def paginate(query, opts \\ []) do + page = max(opts[:page] || 1, 1) + per_page = opts[:per_page] || 25 + + count_query = + query + |> exclude(:preload) + |> exclude(:select) + |> exclude(:order_by) + + total_count = Repo.aggregate(count_query, :count) + total_pages = max(ceil(total_count / per_page), 1) + page = min(page, total_pages) + + items = + query + |> limit(^per_page) + |> offset(^((page - 1) * per_page)) + |> Repo.all() + + %__MODULE__{ + items: items, + page: page, + per_page: per_page, + total_count: total_count, + total_pages: total_pages + } + end + + @doc """ + Returns "Showing X\u2013Y of Z" for display, or "No results" when empty. + """ + def showing_text(%__MODULE__{total_count: 0}), do: "No results" + + def showing_text(%__MODULE__{} = p) do + first = (p.page - 1) * p.per_page + 1 + last = min(p.page * p.per_page, p.total_count) + "Showing #{first}\u2013#{last} of #{p.total_count}" + end + + @doc """ + Computes page numbers with `:ellipsis` gaps for display. + + Always shows first, last, and a window around the current page. + Returns a flat list like `[1, :ellipsis, 4, 5, 6, :ellipsis, 20]`. + """ + def page_numbers(%__MODULE__{total_pages: total}) when total <= 7 do + Enum.to_list(1..total) + end + + def page_numbers(%__MODULE__{page: current, total_pages: total}) do + window = + [current - 1, current, current + 1] + |> Enum.filter(&(&1 >= 1 and &1 <= total)) + + pages = Enum.uniq([1] ++ window ++ [total]) |> Enum.sort() + + # Insert :ellipsis between non-consecutive numbers + pages + |> Enum.chunk_every(2, 1, :discard) + |> Enum.flat_map(fn [a, b] -> + if b - a > 1, do: [a, :ellipsis], else: [a] + end) + |> Kernel.++([List.last(pages)]) + end + + @doc """ + Parses a page number from request params. Always returns >= 1. + """ + def parse_page(params, key \\ "page") do + case params[key] do + nil -> + 1 + + val when is_binary(val) -> + case Integer.parse(val) do + {n, _} when n > 0 -> n + _ -> 1 + end + + val when is_integer(val) and val > 0 -> + val + + _ -> + 1 + end + end +end diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex index 4eeb4fd..daf1fe2 100644 --- a/lib/berrypod/products.ex +++ b/lib/berrypod/products.ex @@ -171,6 +171,21 @@ defmodule Berrypod.Products do |> Repo.all() end + @doc """ + Like `list_visible_products/1` but returns a `%Pagination{}` struct. + + Accepts the same filter/sort options plus `:page` and `:per_page`. + """ + def list_visible_products_paginated(opts \\ []) do + Product + |> where([p], p.visible == true and p.status == "active") + |> apply_visible_filters(opts) + |> apply_sort(opts[:sort]) + |> maybe_exclude(opts[:exclude]) + |> preload(^@listing_preloads) + |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24) + end + @doc """ Lists distinct categories from visible, active products. Returns a list of `%{name, slug, image_url}` where `image_url` is the @@ -308,6 +323,20 @@ defmodule Berrypod.Products do |> Repo.all() end + @doc """ + Like `list_products_admin/1` but returns a `%Pagination{}` struct. + + Accepts the same filter options plus `:page` and `:per_page`. + """ + def list_products_admin_paginated(opts \\ []) do + Product + |> apply_product_filters(opts) + |> maybe_filter_in_stock(opts[:in_stock]) + |> apply_sort(opts[:sort]) + |> preload([:provider_connection, images: :image, variants: []]) + |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25) + end + @doc """ Returns distinct category names from all products (including hidden/draft). """ diff --git a/lib/berrypod/redirects.ex b/lib/berrypod/redirects.ex index 38bfa1aa..72c19c4 100644 --- a/lib/berrypod/redirects.ex +++ b/lib/berrypod/redirects.ex @@ -217,6 +217,11 @@ defmodule Berrypod.Redirects do |> Repo.all() end + def list_redirects_paginated(opts \\ []) do + from(r in Redirect, order_by: [desc: r.inserted_at]) + |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25) + end + @doc """ Gets a single redirect by ID. """ @@ -275,6 +280,16 @@ defmodule Berrypod.Redirects do |> Repo.all() end + def list_broken_urls_paginated(opts \\ []) do + status = opts[:status] || "pending" + + from(b in BrokenUrl, + where: b.status == ^status, + order_by: [desc: b.prior_analytics_hits, desc: b.recent_404_count] + ) + |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25) + end + @doc """ Resolves a broken URL by creating a redirect and updating the record. """ diff --git a/lib/berrypod_web/components/core_components.ex b/lib/berrypod_web/components/core_components.ex index 2c9d77f..27f9bf3 100644 --- a/lib/berrypod_web/components/core_components.ex +++ b/lib/berrypod_web/components/core_components.ex @@ -333,34 +333,36 @@ defmodule BerrypodWeb.CoreComponents do end ~H""" -
| {col[:label]} | -- {gettext("Actions")} - | -
|---|---|
| - {render_slot(col, @row_item.(row))} - | -
-
- <%= for action <- @action do %>
- {render_slot(action, @row_item.(row))}
- <% end %>
-
- |
-
| {col[:label]} | ++ {gettext("Actions")} + | +
|---|---|
| + {render_slot(col, @row_item.(row))} + | +
+
+ <%= for action <- @action do %>
+ {render_slot(action, @row_item.(row))}
+ <% end %>
+
+ |
+
No subscribers yet
@@ -336,6 +350,7 @@ defmodule BerrypodWeb.Admin.Newsletter do attr :streams, :any, required: true attr :campaign_count, :integer, required: true + attr :campaign_pagination, Berrypod.Pagination, required: true defp campaigns_tab(assigns) do ~H""" @@ -370,6 +385,13 @@ defmodule BerrypodWeb.Admin.Newsletter do + <.admin_pagination + :if={@campaign_count > 0} + page={@campaign_pagination} + patch={~p"/admin/newsletter"} + params={%{"tab" => "campaigns"}} + /> +No campaigns yet
diff --git a/lib/berrypod_web/live/admin/orders.ex b/lib/berrypod_web/live/admin/orders.ex index 90f8049..f3eee8e 100644 --- a/lib/berrypod_web/live/admin/orders.ex +++ b/lib/berrypod_web/live/admin/orders.ex @@ -7,32 +7,38 @@ defmodule BerrypodWeb.Admin.Orders do @impl true def mount(_params, _session, socket) do counts = Orders.count_orders_by_status() - orders = Orders.list_orders() socket = socket |> assign(:page_title, "Orders") |> assign(:status_filter, "all") |> assign(:status_counts, counts) - |> assign(:order_count, length(orders)) - |> stream(:orders, orders) {:ok, socket} end @impl true - def handle_event("filter", %{"status" => status}, socket) do - orders = Orders.list_orders(status: status) + def handle_params(params, _uri, socket) do + page_num = Berrypod.Pagination.parse_page(params) + page = Orders.list_orders_paginated(status: socket.assigns.status_filter, page: page_num) socket = socket - |> assign(:status_filter, status) - |> assign(:order_count, length(orders)) - |> stream(:orders, orders, reset: true) + |> assign(:pagination, page) + |> assign(:order_count, page.total_count) + |> stream(:orders, page.items, reset: true) {:noreply, socket} end + @impl true + def handle_event("filter", %{"status" => status}, socket) do + {:noreply, + socket + |> assign(:status_filter, status) + |> push_patch(to: ~p"/admin/orders")} + end + @impl true def render(assigns) do ~H""" @@ -90,6 +96,8 @@ defmodule BerrypodWeb.Admin.Orders do + <.admin_pagination :if={@order_count > 0} page={@pagination} patch={~p"/admin/orders"} /> +No orders yet
diff --git a/lib/berrypod_web/live/admin/products.ex b/lib/berrypod_web/live/admin/products.ex index 2036835..49696b3 100644 --- a/lib/berrypod_web/live/admin/products.ex +++ b/lib/berrypod_web/live/admin/products.ex @@ -9,55 +9,58 @@ defmodule BerrypodWeb.Admin.Products do def mount(_params, _session, socket) do connections = Products.list_provider_connections() categories = Products.list_all_categories() - products = Products.list_products_admin() socket = socket |> assign(:page_title, "Products") |> assign(:connections, connections) |> assign(:categories, categories) - |> assign(:product_count, length(products)) |> assign(:provider_filter, "all") |> assign(:category_filter, "all") |> assign(:visibility_filter, "all") |> assign(:stock_filter, "all") |> assign(:sort, "newest") - |> stream(:products, products) {:ok, socket} end @impl true - def handle_event("filter", params, socket) do - provider_filter = params["provider"] || socket.assigns.provider_filter - category_filter = params["category"] || socket.assigns.category_filter - visibility_filter = params["visibility"] || socket.assigns.visibility_filter - stock_filter = params["stock"] || socket.assigns.stock_filter - sort = params["sort"] || socket.assigns.sort + def handle_params(params, _uri, socket) do + page_num = Berrypod.Pagination.parse_page(params) opts = - [] - |> maybe_add_filter(:provider_connection_id, provider_filter) - |> maybe_add_filter(:category, category_filter) - |> maybe_add_visibility(visibility_filter) - |> maybe_add_stock(stock_filter) - |> Keyword.put(:sort, sort) + build_filter_opts( + socket.assigns.provider_filter, + socket.assigns.category_filter, + socket.assigns.visibility_filter, + socket.assigns.stock_filter, + socket.assigns.sort + ) - products = Products.list_products_admin(opts) + page = Products.list_products_admin_paginated([page: page_num] ++ opts) socket = socket - |> assign(:provider_filter, provider_filter) - |> assign(:category_filter, category_filter) - |> assign(:visibility_filter, visibility_filter) - |> assign(:stock_filter, stock_filter) - |> assign(:sort, sort) - |> assign(:product_count, length(products)) - |> stream(:products, products, reset: true) + |> assign(:pagination, page) + |> assign(:product_count, page.total_count) + |> stream(:products, page.items, reset: true) {:noreply, socket} end + @impl true + def handle_event("filter", params, socket) do + socket = + socket + |> assign(:provider_filter, params["provider"] || socket.assigns.provider_filter) + |> assign(:category_filter, params["category"] || socket.assigns.category_filter) + |> assign(:visibility_filter, params["visibility"] || socket.assigns.visibility_filter) + |> assign(:stock_filter, params["stock"] || socket.assigns.stock_filter) + |> assign(:sort, params["sort"] || socket.assigns.sort) + + {:noreply, push_patch(socket, to: ~p"/admin/products")} + end + @impl true def handle_event("toggle_visibility", %{"id" => id}, socket) do product = @@ -177,6 +180,8 @@ defmodule BerrypodWeb.Admin.Products do + <.admin_pagination :if={@product_count > 0} page={@pagination} patch={~p"/admin/products"} /> +No products yet
@@ -272,6 +277,15 @@ defmodule BerrypodWeb.Admin.Products do # Filter helpers # --------------------------------------------------------------------------- + defp build_filter_opts(provider, category, visibility, stock, sort) do + [] + |> maybe_add_filter(:provider_connection_id, provider) + |> maybe_add_filter(:category, category) + |> maybe_add_visibility(visibility) + |> maybe_add_stock(stock) + |> Keyword.put(:sort, sort) + end + defp maybe_add_filter(opts, _key, "all"), do: opts defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value) diff --git a/lib/berrypod_web/live/admin/redirects.ex b/lib/berrypod_web/live/admin/redirects.ex index 08d19b6..2bbb651 100644 --- a/lib/berrypod_web/live/admin/redirects.ex +++ b/lib/berrypod_web/live/admin/redirects.ex @@ -9,11 +9,16 @@ defmodule BerrypodWeb.Admin.Redirects do def mount(_params, _session, socket) do if connected?(socket), do: Redirects.subscribe() + redirect_page = Redirects.list_redirects_paginated(page: 1) + broken_page = Redirects.list_broken_urls_paginated(page: 1) + socket = socket |> assign(:page_title, "Redirects") - |> assign(:redirects, Redirects.list_redirects()) - |> assign(:broken_urls, Redirects.list_broken_urls()) + |> assign(:redirect_pagination, redirect_page) + |> assign(:broken_url_pagination, broken_page) + |> stream(:redirects, redirect_page.items) + |> stream(:broken_urls, broken_page.items) |> assign( :form, to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect) @@ -25,16 +30,50 @@ defmodule BerrypodWeb.Admin.Redirects do @impl true def handle_params(params, _uri, socket) do tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "redirects" - {:noreply, assign(socket, :tab, tab)} + page_num = Berrypod.Pagination.parse_page(params) + + socket = assign(socket, :tab, tab) + + socket = + case tab do + "redirects" -> + page = Redirects.list_redirects_paginated(page: page_num) + + socket + |> assign(:redirect_pagination, page) + |> stream(:redirects, page.items, reset: true) + + "broken" -> + page = Redirects.list_broken_urls_paginated(page: page_num) + + socket + |> assign(:broken_url_pagination, page) + |> stream(:broken_urls, page.items, reset: true) + + _ -> + socket + end + + {:noreply, socket} end @impl true def handle_info({:redirects_changed, _action}, socket) do - {:noreply, assign(socket, :redirects, Redirects.list_redirects())} + page = Redirects.list_redirects_paginated(page: socket.assigns.redirect_pagination.page) + + {:noreply, + socket + |> assign(:redirect_pagination, page) + |> stream(:redirects, page.items, reset: true)} end def handle_info({:broken_urls_changed, _path}, socket) do - {:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())} + page = Redirects.list_broken_urls_paginated(page: socket.assigns.broken_url_pagination.page) + + {:noreply, + socket + |> assign(:broken_url_pagination, page) + |> stream(:broken_urls, page.items, reset: true)} end @impl true @@ -43,10 +82,15 @@ defmodule BerrypodWeb.Admin.Redirects do end def handle_event("delete_redirect", %{"id" => id}, socket) do - redirect = Redirects.get_redirect!(id) - {:ok, _} = Redirects.delete_redirect(redirect) + redirect_rec = Redirects.get_redirect!(id) + {:ok, _} = Redirects.delete_redirect(redirect_rec) - {:noreply, assign(socket, :redirects, Redirects.list_redirects())} + page = Redirects.list_redirects_paginated(page: socket.assigns.redirect_pagination.page) + + {:noreply, + socket + |> assign(:redirect_pagination, page) + |> stream(:redirects, page.items, reset: true)} end def handle_event("create_redirect", %{"redirect" => params}, socket) do @@ -80,7 +124,12 @@ defmodule BerrypodWeb.Admin.Redirects do broken_url = Redirects.get_broken_url!(id) {:ok, _} = Redirects.ignore_broken_url(broken_url) - {:noreply, assign(socket, :broken_urls, Redirects.list_broken_urls())} + page = Redirects.list_broken_urls_paginated(page: socket.assigns.broken_url_pagination.page) + + {:noreply, + socket + |> assign(:broken_url_pagination, page) + |> stream(:broken_urls, page.items, reset: true)} end def handle_event("redirect_broken_url", %{"path" => path}, socket) do @@ -103,17 +152,27 @@ defmodule BerrypodWeb.Admin.Redirects doNo redirects yet.
<% else %> -| From | -To | -Source | -Hits | -Created | -- |
|---|
| From | +To | +Source | +Hits | +Created | ++ |
|---|---|---|---|---|---|
{redirect.from_path} |
{redirect.to_path} |
@@ -161,32 +220,38 @@ defmodule BerrypodWeb.Admin.Redirects do |
No broken URLs detected.
<% else %> -| Path | -Prior traffic | -404s | -First seen | -Last seen | -- |
|---|
| Path | +Prior traffic | +404s | +First seen | +Last seen | ++ |
|---|---|---|---|---|---|
{broken_url.path} |
{broken_url.prior_analytics_hits} | {broken_url.recent_404_count} | @@ -209,9 +274,15 @@ defmodule BerrypodWeb.Admin.Redirects do
No products found in this collection.
diff --git a/priv/repo/dev_seeds.exs b/priv/repo/dev_seeds.exs new file mode 100644 index 0000000..f315946 --- /dev/null +++ b/priv/repo/dev_seeds.exs @@ -0,0 +1,399 @@ +# Dev seed data for testing pagination. +# +# mix run priv/repo/dev_seeds.exs +# +# Creates enough records to exercise pagination in every admin and shop view. +# Safe to run multiple times — skips if data already exists. + +alias Berrypod.Repo +alias Berrypod.Products +alias Berrypod.Products.{Product, ProductImage, ProductVariant, ProviderConnection} +alias Berrypod.Orders +alias Berrypod.Orders.{Order, OrderItem} +alias Berrypod.Media.Image +alias Berrypod.Newsletter +alias Berrypod.Redirects +alias Berrypod.Redirects.{Redirect, BrokenUrl} + +import Ecto.Query + +# ── Helpers ── + +defmodule DevSeeds do + @adj ~w[rustic vintage modern classic artisan handmade organic natural premium deluxe] + @noun ~w[mug poster print tote hoodie tee cap sticker notebook candle] + @colour ~w[crimson slate sage coral midnight forest amber ivory charcoal dusty-rose] + @size ~w[XS S M L XL 2XL] + + @first_names ~w[Alice Bob Charlie Diana Ethan Fiona George Hannah Ivan Julia + Kevin Laura Mike Nora Oscar Penny Quinn Rosa Sam Tina] + @last_names ~w[Smith Jones Brown Taylor Wilson Davies Evans Thomas Roberts Johnson + Walker White Harris Martin Thompson Garcia Martinez Robinson Clark Lewis] + @domains ~w[gmail.com outlook.com yahoo.co.uk hotmail.com protonmail.com icloud.com] + + @campaign_subjects [ + "New arrivals just dropped", + "Spring sale — 20% off everything", + "Your exclusive early access", + "Back in stock: fan favourites", + "Free shipping this weekend only", + "Our story so far", + "Last chance: sale ends tonight", + "Meet the maker", + "Gift guide for every budget", + "Something special is coming" + ] + + def product_title(i) do + adj = Enum.at(@adj, rem(i, length(@adj))) + noun = Enum.at(@noun, rem(div(i, length(@adj)), length(@noun))) + "#{String.capitalize(adj)} #{noun}" + end + + def category(i) do + cats = ["Apparel", "Homeware", "Stationery", "Accessories", "Art Prints"] + Enum.at(cats, rem(i, length(cats))) + end + + def colour(i), do: Enum.at(@colour, rem(i, length(@colour))) + def size(i), do: Enum.at(@size, rem(i, length(@size))) + + def customer_email(i) do + first = Enum.at(@first_names, rem(i, length(@first_names))) + last = Enum.at(@last_names, rem(div(i, length(@first_names)), length(@last_names))) + domain = Enum.at(@domains, rem(i, length(@domains))) + "#{String.downcase(first)}.#{String.downcase(last)}@#{domain}" + end + + def subscriber_email(i) do + first = Enum.at(@first_names, rem(i, length(@first_names))) + last = Enum.at(@last_names, rem(div(i + 3, length(@first_names)), length(@last_names))) + domain = Enum.at(@domains, rem(i + 2, length(@domains))) + "#{String.downcase(first)}.#{String.downcase(last)}+news@#{domain}" + end + + def campaign_subject(i) do + base = Enum.at(@campaign_subjects, rem(i, length(@campaign_subjects))) + if i >= length(@campaign_subjects), do: "#{base} (#{div(i, length(@campaign_subjects)) + 1})", else: base + end + + def random_price, do: Enum.random(999..4999) + def random_cost(price), do: div(price, 2) +end + +# ── Guard: skip if data already exists ── + +existing_products = Repo.aggregate(Product, :count) + +if existing_products >= 60 do + IO.puts("Already have #{existing_products} products — skipping dev seeds.") +else + # ── 1. Provider connection ── + + IO.puts("Creating provider connection...") + + conn = + case Products.get_provider_connection_by_type("printify") do + nil -> + {:ok, c} = + Products.create_provider_connection(%{ + provider_type: "printify", + name: "Dev Printify Shop", + api_key: "dev_test_key_123", + config: %{"shop_id" => "dev_shop"} + }) + c + + existing -> + existing + end + + # ── 2. Products (60 — enough for 3 pages at 25/page in admin, 3 pages at 24/page in shop) ── + + IO.puts("Creating 60 products with variants...") + + # Tiny placeholder image (1x1 white pixel WebP) + pixel_webp = + <<82, 73, 70, 70, 36, 0, 0, 0, 87, 69, 66, 80, 86, 80, 56, 32, + 16, 0, 0, 0, 48, 1, 0, 157, 1, 42, 1, 0, 1, 0, 1, 0, 52, 37, + 164, 0, 3, 112, 0, 254, 251, 148, 0, 0>> + + for i <- 0..59 do + title = DevSeeds.product_title(i) + slug = Slug.slugify(title) <> "-#{i}" + price = DevSeeds.random_price() + on_sale = rem(i, 7) == 0 + + {:ok, product} = + Products.create_product(%{ + provider_connection_id: conn.id, + provider_product_id: "dev_prod_#{i}", + title: title, + description: "A lovely #{String.downcase(title)} for your collection. Hand-picked and quality-checked.", + slug: slug, + status: "active", + visible: true, + category: DevSeeds.category(i), + cheapest_price: price, + compare_at_price: if(on_sale, do: price + Enum.random(500..1500), else: nil), + on_sale: on_sale, + in_stock: rem(i, 20) != 0, + provider_data: %{ + "options" => [ + %{"type" => "color", "name" => "Colour", "values" => [ + %{"title" => DevSeeds.colour(i), "colors" => ["#666"]}, + %{"title" => DevSeeds.colour(i + 1), "colors" => ["#999"]} + ]}, + %{"type" => "size", "name" => "Size", "values" => [ + %{"title" => "S"}, %{"title" => "M"}, %{"title" => "L"} + ]} + ] + } + }) + + # Create a product image (using a stored Image record) + {:ok, img} = + %Image{} + |> Image.changeset(%{ + image_type: "product", + filename: "#{slug}.webp", + content_type: "image/webp", + file_size: byte_size(pixel_webp), + data: pixel_webp, + source_width: 1, + source_height: 1, + variants_status: "complete", + alt: title + }) + |> Repo.insert() + + Products.create_product_image(%{ + product_id: product.id, + src: "/images/#{img.id}/original", + image_id: img.id, + position: 0, + alt: title + }) + + # Create 3 variants per product + for size_idx <- 0..2 do + size = DevSeeds.size(size_idx + 1) + colour = DevSeeds.colour(i) + variant_price = price + size_idx * 200 + + Products.create_product_variant(%{ + product_id: product.id, + provider_variant_id: "dev_var_#{i}_#{size_idx}", + title: "#{size} / #{colour}", + sku: "DEV-#{String.upcase(String.slice(slug, 0..5))}-#{size}", + price: variant_price, + compare_at_price: if(on_sale, do: variant_price + Enum.random(500..1500), else: nil), + cost: DevSeeds.random_cost(variant_price), + options: %{"Size" => size, "Colour" => colour}, + is_enabled: true, + is_available: rem(i, 20) != 0 + }) + end + + if rem(i + 1, 20) == 0, do: IO.puts(" #{i + 1}/60 products...") + end + + IO.puts(" 60/60 products done.") + + # ── 3. Extra media images (enough to fill 2+ pages at 36/page = 80 total, minus the 60 product images) ── + + IO.puts("Creating 30 extra media images...") + + for i <- 0..29 do + %Image{} + |> Image.changeset(%{ + image_type: Enum.at(["media", "header", "icon"], rem(i, 3)), + filename: "media-image-#{i}.webp", + content_type: "image/webp", + file_size: byte_size(pixel_webp), + data: pixel_webp, + source_width: 1, + source_height: 1, + variants_status: "complete", + alt: "Media image #{i + 1}", + tags: Enum.at(["banner", "promo", "lifestyle", "texture"], rem(i, 4)) + }) + |> Repo.insert!() + end + + # ── 4. Orders (60 — enough for 3 pages at 25/page) ── + + IO.puts("Creating 60 orders...") + + statuses = ["paid", "paid", "paid", "pending", "failed", "refunded"] + fulfilment_statuses = ["unfulfilled", "submitted", "processing", "shipped", "delivered"] + + for i <- 0..59 do + email = DevSeeds.customer_email(i) + price = DevSeeds.random_price() + qty = Enum.random(1..3) + subtotal = price * qty + payment_status = Enum.at(statuses, rem(i, length(statuses))) + + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + # Spread orders over the last 90 days + days_ago = 90 - div(i * 90, 60) + inserted_at = NaiveDateTime.add(now, -days_ago * 86400, :second) + + order_attrs = %{ + order_number: "SS-260#{String.pad_leading("#{rem(200 + i, 300)}", 3, "0")}01-#{String.pad_leading("#{1000 + i}", 4, "0")}", + subtotal: subtotal, + shipping_cost: if(subtotal > 3000, do: 0, else: 399), + total: subtotal + if(subtotal > 3000, do: 0, else: 399), + currency: "gbp", + customer_email: email, + payment_status: payment_status, + shipping_address: %{ + "name" => String.split(email, [".", "@"]) |> Enum.take(2) |> Enum.map(&String.capitalize/1) |> Enum.join(" "), + "line1" => "#{Enum.random(1..200)} #{Enum.at(~w[High Oak Elm Mill Church Park], rem(i, 6))} Street", + "city" => Enum.at(~w[London Manchester Bristol Edinburgh Cardiff Leeds], rem(i, 6)), + "postal_code" => "#{Enum.at(~w[SW1A EC2R BS1 EH1 CF10 LS1], rem(i, 6))} #{Enum.random(1..9)}#{Enum.at(~w[AA AB BA BB], rem(i, 4))}", + "country" => "GB" + }, + fulfilment_status: + if(payment_status == "paid", + do: Enum.at(fulfilment_statuses, rem(i, length(fulfilment_statuses))), + else: "unfulfilled"), + inserted_at: inserted_at, + updated_at: inserted_at + } + + {:ok, order} = + %Order{} + |> Order.changeset(order_attrs) + |> Repo.insert() + + # Create 1-2 line items + for j <- 0..(qty - 1) do + prod_idx = rem(i + j, 60) + + %OrderItem{} + |> OrderItem.changeset(%{ + order_id: order.id, + variant_id: "dev_var_#{prod_idx}_0", + product_id: "dev_prod_#{prod_idx}", + product_name: DevSeeds.product_title(prod_idx), + variant_title: "M / #{DevSeeds.colour(prod_idx)}", + quantity: 1, + unit_price: price + }) + |> Repo.insert!() + end + + if rem(i + 1, 20) == 0, do: IO.puts(" #{i + 1}/60 orders...") + end + + IO.puts(" 60/60 orders done.") + + # ── 5. Newsletter subscribers (60 — enough for 3 pages at 25/page) ── + + IO.puts("Creating 60 newsletter subscribers...") + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + for i <- 0..59 do + email = DevSeeds.subscriber_email(i) + status = Enum.at(["confirmed", "confirmed", "confirmed", "pending", "unsubscribed"], rem(i, 5)) + + %Newsletter.Subscriber{} + |> Newsletter.Subscriber.changeset(%{ + email: email, + status: status, + confirmed_at: if(status == "confirmed", do: now, else: nil), + unsubscribed_at: if(status == "unsubscribed", do: now, else: nil), + consent_text: "Signed up via dev seeds", + source: Enum.at(["website", "checkout", "import"], rem(i, 3)) + }) + |> Repo.insert!() + end + + # ── 6. Newsletter campaigns (30 — enough for 2 pages at 25/page) ── + + IO.puts("Creating 30 newsletter campaigns...") + + for i <- 0..29 do + status = Enum.at(["draft", "sent", "sent", "sent", "scheduled"], rem(i, 5)) + + %Newsletter.Campaign{} + |> Newsletter.Campaign.changeset(%{ + subject: DevSeeds.campaign_subject(i), + body: """ + Hi there! + + #{DevSeeds.campaign_subject(i)} — check out what's new in the shop. + + Visit us at https://example.com + + Unsubscribe: {{unsubscribe_url}} + """, + status: status, + sent_at: if(status == "sent", do: now, else: nil), + sent_count: if(status == "sent", do: Enum.random(20..55), else: 0), + failed_count: if(status == "sent", do: Enum.random(0..3), else: 0), + scheduled_at: if(status == "scheduled", do: DateTime.add(now, 7, :day), else: nil) + }) + |> Repo.insert!() + end + + # ── 7. Redirects (30 — enough for 2 pages at 25/page) ── + + IO.puts("Creating 30 redirects...") + + Redirects.create_table() + + for i <- 0..29 do + source = Enum.at(["auto_slug_change", "admin", "auto_product_deleted", "analytics_auto_resolved"], rem(i, 4)) + old_slug = "old-product-#{i}-#{:rand.uniform(9999)}" + new_slug = "new-product-#{i}" + + %Redirect{} + |> Redirect.changeset(%{ + from_path: "/products/#{old_slug}", + to_path: "/products/#{new_slug}", + status_code: if(rem(i, 5) == 0, do: 302, else: 301), + source: source, + hit_count: Enum.random(0..150) + }) + |> Repo.insert!() + end + + # ── 8. Broken URLs (30 — enough for 2 pages at 25/page) ── + + IO.puts("Creating 30 broken URLs...") + + for i <- 0..29 do + days_ago = Enum.random(1..60) + first_seen = DateTime.add(now, -days_ago * 86400, :second) + + %BrokenUrl{} + |> BrokenUrl.changeset(%{ + path: "/products/missing-item-#{i}-#{:rand.uniform(9999)}", + prior_analytics_hits: Enum.random(0..500), + recent_404_count: Enum.random(1..50), + first_seen_at: first_seen, + last_seen_at: DateTime.add(first_seen, Enum.random(0..days_ago) * 86400, :second), + status: "pending" + }) + |> Repo.insert!() + end + + # Warm the redirects cache so they show up immediately + Redirects.warm_cache() + + IO.puts("") + IO.puts("Dev seed data created:") + IO.puts(" 60 products (with variants + images)") + IO.puts(" 90 media images (60 product + 30 extra)") + IO.puts(" 60 orders") + IO.puts(" 60 newsletter subscribers") + IO.puts(" 30 newsletter campaigns") + IO.puts(" 30 redirects") + IO.puts(" 30 broken URLs") + IO.puts("") + IO.puts("All views should now show pagination controls.") +end diff --git a/test/berrypod/newsletter/notifier_test.exs b/test/berrypod/newsletter/notifier_test.exs index 25fa2ae..08589d9 100644 --- a/test/berrypod/newsletter/notifier_test.exs +++ b/test/berrypod/newsletter/notifier_test.exs @@ -84,7 +84,8 @@ defmodule Berrypod.Newsletter.NotifierTest do describe "deliver_test/2" do test "sends test email with [Test] prefix in subject" do - campaign = campaign_fixture(subject: "Big launch", body: "Preview this!\n\n{{unsubscribe_url}}") + campaign = + campaign_fixture(subject: "Big launch", body: "Preview this!\n\n{{unsubscribe_url}}") assert {:ok, _} = Notifier.deliver_test(campaign, "admin@example.com") diff --git a/test/berrypod/pagination_test.exs b/test/berrypod/pagination_test.exs new file mode 100644 index 0000000..a84cd98 --- /dev/null +++ b/test/berrypod/pagination_test.exs @@ -0,0 +1,155 @@ +defmodule Berrypod.PaginationTest do + use Berrypod.DataCase, async: true + + alias Berrypod.Pagination + + describe "page_numbers/1" do + test "returns all pages when total <= 7" do + assert Pagination.page_numbers(page(1, 1)) == [1] + assert Pagination.page_numbers(page(1, 5)) == [1, 2, 3, 4, 5] + assert Pagination.page_numbers(page(3, 7)) == [1, 2, 3, 4, 5, 6, 7] + end + + test "shows ellipsis for large page counts" do + result = Pagination.page_numbers(page(1, 20)) + assert result == [1, 2, :ellipsis, 20] + end + + test "shows window around current page" do + result = Pagination.page_numbers(page(10, 20)) + assert result == [1, :ellipsis, 9, 10, 11, :ellipsis, 20] + end + + test "current page near start" do + result = Pagination.page_numbers(page(2, 20)) + assert result == [1, 2, 3, :ellipsis, 20] + end + + test "current page near end" do + result = Pagination.page_numbers(page(19, 20)) + assert result == [1, :ellipsis, 18, 19, 20] + end + + test "current page at last" do + result = Pagination.page_numbers(page(20, 20)) + assert result == [1, :ellipsis, 19, 20] + end + + test "8 pages with current at 4" do + result = Pagination.page_numbers(page(4, 8)) + assert result == [1, :ellipsis, 3, 4, 5, :ellipsis, 8] + end + end + + describe "showing_text/1" do + test "returns showing range" do + assert Pagination.showing_text(page(1, 5, 25, 120)) == "Showing 1\u201325 of 120" + assert Pagination.showing_text(page(2, 5, 25, 120)) == "Showing 26\u201350 of 120" + assert Pagination.showing_text(page(5, 5, 25, 120)) == "Showing 101\u2013120 of 120" + end + + test "returns no results when empty" do + assert Pagination.showing_text(page(1, 1, 25, 0)) == "No results" + end + end + + describe "parse_page/1" do + test "returns 1 for nil" do + assert Pagination.parse_page(%{}) == 1 + end + + test "parses valid string" do + assert Pagination.parse_page(%{"page" => "3"}) == 3 + end + + test "returns 1 for invalid string" do + assert Pagination.parse_page(%{"page" => "abc"}) == 1 + assert Pagination.parse_page(%{"page" => "0"}) == 1 + assert Pagination.parse_page(%{"page" => "-1"}) == 1 + end + + test "accepts integer values" do + assert Pagination.parse_page(%{"page" => 5}) == 5 + end + + test "uses custom key" do + assert Pagination.parse_page(%{"p" => "7"}, "p") == 7 + end + end + + describe "paginate/2" do + test "paginates a query" do + # Create some subscribers as test data + for i <- 1..7 do + %Berrypod.Newsletter.Subscriber{} + |> Berrypod.Newsletter.Subscriber.changeset(%{ + email: "pag#{i}@example.com", + status: "confirmed", + confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second), + consent_text: "test" + }) + |> Berrypod.Repo.insert!() + end + + import Ecto.Query + + query = + from(s in Berrypod.Newsletter.Subscriber, + order_by: [asc: s.email] + ) + + # Page 1 of 3 (3 per page, 7 total) + result = Pagination.paginate(query, page: 1, per_page: 3) + assert length(result.items) == 3 + assert result.page == 1 + assert result.per_page == 3 + assert result.total_count == 7 + assert result.total_pages == 3 + + # Page 3 has 1 item + result = Pagination.paginate(query, page: 3, per_page: 3) + assert length(result.items) == 1 + assert result.page == 3 + + # Page beyond range is clamped + result = Pagination.paginate(query, page: 99, per_page: 3) + assert result.page == 3 + end + + test "handles empty result set" do + import Ecto.Query + + query = + from(s in Berrypod.Newsletter.Subscriber, + where: s.email == "nonexistent@nope.com" + ) + + result = Pagination.paginate(query, page: 1, per_page: 25) + assert result.items == [] + assert result.total_count == 0 + assert result.total_pages == 1 + assert result.page == 1 + end + end + + # Helpers for building test structs + defp page(current, total) do + %Pagination{ + items: [], + page: current, + per_page: 25, + total_count: total * 25, + total_pages: total + } + end + + defp page(current, total_pages, per_page, total_count) do + %Pagination{ + items: [], + page: current, + per_page: per_page, + total_count: total_count, + total_pages: total_pages + } + end +end