add pagination across all admin and shop views
All checks were successful
deploy / deploy (push) Successful in 1m38s

URL-based offset pagination with ?page=N for bookmarkable pages.
Admin views use push_patch, shop collection uses navigate links.
Responsive on mobile with horizontal-scroll tables and stacking
pagination controls. Includes dev seed script for testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-03-01 09:42:34 +00:00
parent 7f6fd012a5
commit 3480b326a9
21 changed files with 1485 additions and 211 deletions

View File

@ -22,6 +22,8 @@
min-height: 100vh; min-height: 100vh;
min-height: 100dvh; min-height: 100dvh;
grid-column: 2; grid-column: 2;
min-width: 0;
overflow-x: hidden;
} }
.admin-sidebar-wrapper { .admin-sidebar-wrapper {
@ -249,6 +251,11 @@
/* ── Tables ── */ /* ── Tables ── */
.admin-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.admin-table { .admin-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@ -2125,12 +2132,15 @@
min-height: 400px; min-height: 400px;
} }
.media-grid-wrapper {
flex: 1;
min-width: 0;
}
.media-grid { .media-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.75rem; gap: 0.75rem;
flex: 1;
min-width: 0;
align-content: start; 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 */ } /* @layer admin */

View File

@ -467,6 +467,102 @@
font-size: var(--t-text-small); 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 ── */
.search-page-form { .search-page-form {

View File

@ -213,6 +213,20 @@ defmodule Berrypod.Media do
|> Repo.all() |> Repo.all()
end 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, nil), do: query
defp filter_by_type(query, ""), do: query defp filter_by_type(query, ""), do: query
defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type) defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type)

View File

@ -162,6 +162,29 @@ defmodule Berrypod.Newsletter do
Repo.all(query) Repo.all(query)
end 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." @doc "Returns subscriber counts grouped by status."
def count_subscribers_by_status do def count_subscribers_by_status do
from(s in Subscriber, from(s in Subscriber,
@ -228,6 +251,11 @@ defmodule Berrypod.Newsletter do
|> Repo.all() |> Repo.all()
end 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 get_campaign!(id), do: Repo.get!(Campaign, id)
def create_campaign(attrs) do def create_campaign(attrs) do

View File

@ -146,6 +146,7 @@ defmodule Berrypod.Newsletter.Notifier do
@doc false @doc false
def wrap_html(shop_name, content, unsubscribe_url \\ nil) do def wrap_html(shop_name, content, unsubscribe_url \\ nil) do
logo_html = build_logo_html(shop_name) logo_html = build_logo_html(shop_name)
footer = footer =
if unsubscribe_url do if unsubscribe_url do
""" """
@ -244,7 +245,10 @@ defmodule Berrypod.Newsletter.Notifier do
defp has_favicon_icon? do defp has_favicon_icon? do
require Ecto.Query 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 end
# Turns bare URLs in escaped text into clickable links. # Turns bare URLs in escaped text into clickable links.

View File

@ -35,6 +35,17 @@ defmodule Berrypod.Orders do
|> Repo.all() |> Repo.all()
end 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, nil), do: query
defp maybe_filter_status(query, "all"), do: query defp maybe_filter_status(query, "all"), do: query
defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status) defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status)

119
lib/berrypod/pagination.ex Normal file
View File

@ -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

View File

@ -171,6 +171,21 @@ defmodule Berrypod.Products do
|> Repo.all() |> Repo.all()
end 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 """ @doc """
Lists distinct categories from visible, active products. Lists distinct categories from visible, active products.
Returns a list of `%{name, slug, image_url}` where `image_url` is the Returns a list of `%{name, slug, image_url}` where `image_url` is the
@ -308,6 +323,20 @@ defmodule Berrypod.Products do
|> Repo.all() |> Repo.all()
end 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 """ @doc """
Returns distinct category names from all products (including hidden/draft). Returns distinct category names from all products (including hidden/draft).
""" """

View File

@ -217,6 +217,11 @@ defmodule Berrypod.Redirects do
|> Repo.all() |> Repo.all()
end 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 """ @doc """
Gets a single redirect by ID. Gets a single redirect by ID.
""" """
@ -275,6 +280,16 @@ defmodule Berrypod.Redirects do
|> Repo.all() |> Repo.all()
end 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 """ @doc """
Resolves a broken URL by creating a redirect and updating the record. Resolves a broken URL by creating a redirect and updating the record.
""" """

View File

@ -333,6 +333,7 @@ defmodule BerrypodWeb.CoreComponents do
end end
~H""" ~H"""
<div class="admin-table-wrap">
<table class="admin-table admin-table-zebra"> <table class="admin-table admin-table-zebra">
<thead> <thead>
<tr> <tr>
@ -361,6 +362,7 @@ defmodule BerrypodWeb.CoreComponents do
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
""" """
end end
@ -622,4 +624,88 @@ defmodule BerrypodWeb.CoreComponents do
|> JS.exec("close()", to: "##{id}") |> JS.exec("close()", to: "##{id}")
|> JS.pop_focus() |> JS.pop_focus()
end end
# ── Pagination ───────────────────────────────────────────────────
@doc """
Renders pagination controls for admin lists.
Hidden when there's only one page. Shows "Showing X-Y of Z" on the left,
page number buttons with ellipsis on the right.
## Attributes
* `page` - Required. A `%Berrypod.Pagination{}` struct.
* `patch` - Required. Base URL path for pagination links (e.g. "/admin/products").
* `params` - Extra query params to preserve (e.g. %{"tab" => "broken"}). Default `%{}`.
"""
attr :page, Berrypod.Pagination, required: true
attr :patch, :string, required: true
attr :params, :map, default: %{}
def admin_pagination(assigns) do
assigns =
assigns
|> assign(:showing, Berrypod.Pagination.showing_text(assigns.page))
|> assign(:numbers, Berrypod.Pagination.page_numbers(assigns.page))
~H"""
<nav
:if={@page.total_pages > 1}
aria-label="Pagination"
class="admin-pagination"
>
<p class="admin-pagination-showing">{@showing}</p>
<div class="admin-pagination-buttons">
<.link
patch={admin_page_url(@patch, @page.page - 1, @params)}
class={["admin-btn admin-btn-sm admin-btn-ghost", @page.page == 1 && "admin-btn-disabled"]}
aria-label="Previous page"
aria-disabled={@page.page == 1 && "true"}
tabindex={@page.page == 1 && "-1"}
>
<.icon name="hero-chevron-left" class="size-4" />
</.link>
<%= for item <- @numbers do %>
<%= case item do %>
<% :ellipsis -> %>
<span class="admin-pagination-ellipsis" aria-hidden="true">&hellip;</span>
<% n -> %>
<.link
patch={admin_page_url(@patch, n, @params)}
aria-label={"Page #{n}"}
aria-current={n == @page.page && "page"}
class={[
"admin-btn admin-btn-sm",
if(n == @page.page, do: "admin-btn-primary", else: "admin-btn-ghost")
]}
>
{n}
</.link>
<% end %>
<% end %>
<.link
patch={admin_page_url(@patch, @page.page + 1, @params)}
class={[
"admin-btn admin-btn-sm admin-btn-ghost",
@page.page == @page.total_pages && "admin-btn-disabled"
]}
aria-label="Next page"
aria-disabled={@page.page == @page.total_pages && "true"}
tabindex={@page.page == @page.total_pages && "-1"}
>
<.icon name="hero-chevron-right" class="size-4" />
</.link>
</div>
</nav>
"""
end
defp admin_page_url(base, page, params) do
query = if page > 1, do: Map.put(params, "page", to_string(page)), else: params
if query == %{}, do: base, else: base <> "?" <> URI.encode_query(query)
end
end end

View File

@ -1714,4 +1714,84 @@ defmodule BerrypodWeb.ShopComponents.Product do
</div> </div>
""" """
end end
# ── Shop pagination ────────────────────────────────────────────
@doc """
Renders pagination controls for the shop collection page.
Uses URL navigation (`<.link navigate=...>`) so pages are bookmarkable.
## Attributes
* `page` - Required. A `%Berrypod.Pagination{}` struct.
* `base_path` - Required. The base URL path (e.g. "/collections/all").
* `params` - Extra query params to preserve (e.g. %{"sort" => "newest"}).
"""
attr :page, Berrypod.Pagination, required: true
attr :base_path, :string, required: true
attr :params, :map, default: %{}
def shop_pagination(assigns) do
assigns =
assigns
|> assign(:showing, Berrypod.Pagination.showing_text(assigns.page))
|> assign(:numbers, Berrypod.Pagination.page_numbers(assigns.page))
~H"""
<nav
:if={@page.total_pages > 1}
aria-label="Pagination"
class="shop-pagination"
>
<p class="shop-pagination-showing">{@showing}</p>
<div class="shop-pagination-buttons">
<.link
:if={@page.page > 1}
navigate={pagination_url(@base_path, @page.page - 1, @params)}
class="shop-pagination-btn"
aria-label="Previous page"
>
&lsaquo; Prev
</.link>
<%= for item <- @numbers do %>
<%= case item do %>
<% :ellipsis -> %>
<span class="shop-pagination-ellipsis" aria-hidden="true">&hellip;</span>
<% n -> %>
<.link
navigate={pagination_url(@base_path, n, @params)}
aria-label={"Page #{n}"}
aria-current={n == @page.page && "page"}
class={["shop-pagination-btn", n == @page.page && "shop-pagination-btn-active"]}
>
{n}
</.link>
<% end %>
<% end %>
<.link
:if={@page.page < @page.total_pages}
navigate={pagination_url(@base_path, @page.page + 1, @params)}
class="shop-pagination-btn"
aria-label="Next page"
>
Next &rsaquo;
</.link>
</div>
</nav>
"""
end
defp pagination_url(base_path, page, params) do
query = if page > 1, do: Map.put(params, "page", to_string(page)), else: params
if query == %{} do
base_path
else
base_path <> "?" <> URI.encode_query(query)
end
end
end end

View File

@ -5,20 +5,18 @@ defmodule BerrypodWeb.Admin.Media do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
images = Media.list_images()
socket = socket =
socket socket
|> assign(:page_title, "Media") |> assign(:page_title, "Media")
|> assign(:filter_type, nil) |> assign(:filter_type, nil)
|> assign(:filter_search, "") |> assign(:filter_search, "")
|> assign(:filter_orphans, false) |> assign(:filter_orphans, false)
|> assign(:pagination, nil)
|> assign(:selected_image, nil) |> assign(:selected_image, nil)
|> assign(:selected_usages, []) |> assign(:selected_usages, [])
|> assign(:edit_form, nil) |> assign(:edit_form, nil)
|> assign(:upload_alt, "") |> assign(:upload_alt, "")
|> assign(:confirm_delete, false) |> assign(:confirm_delete, false)
|> stream(:images, images)
|> allow_upload(:media_upload, |> allow_upload(:media_upload,
accept: ~w(.png .jpg .jpeg .webp .svg .gif), accept: ~w(.png .jpg .jpeg .webp .svg .gif),
max_entries: 1, max_entries: 1,
@ -30,6 +28,20 @@ defmodule BerrypodWeb.Admin.Media do
{:ok, socket} {:ok, socket}
end end
@impl true
def handle_params(params, _uri, socket) do
page_num = Berrypod.Pagination.parse_page(params)
opts = image_filter_opts(socket)
page = Media.list_images_paginated([page: page_num] ++ opts)
socket =
socket
|> assign(:pagination, page)
|> stream(:images, page.items, reset: true)
{:noreply, socket}
end
defp handle_progress(:media_upload, entry, socket) do defp handle_progress(:media_upload, entry, socket) do
if entry.done? do if entry.done? do
alt = socket.assigns.upload_alt alt = socket.assigns.upload_alt
@ -60,15 +72,25 @@ defmodule BerrypodWeb.Admin.Media do
@impl true @impl true
def handle_event("filter_type", %{"type" => type}, socket) do def handle_event("filter_type", %{"type" => type}, socket) do
type = if type == "", do: nil, else: type type = if type == "", do: nil, else: type
{:noreply, reload_images(assign(socket, :filter_type, type))}
{:noreply,
socket
|> assign(:filter_type, type)
|> reload_images()}
end end
def handle_event("filter_search", %{"value" => value}, socket) do def handle_event("filter_search", %{"value" => value}, socket) do
{:noreply, reload_images(assign(socket, :filter_search, value))} {:noreply,
socket
|> assign(:filter_search, value)
|> reload_images()}
end end
def handle_event("toggle_orphans", _params, socket) do def handle_event("toggle_orphans", _params, socket) do
{:noreply, reload_images(assign(socket, :filter_orphans, !socket.assigns.filter_orphans))} {:noreply,
socket
|> assign(:filter_orphans, !socket.assigns.filter_orphans)
|> reload_images()}
end end
def handle_event("select_image", %{"id" => id}, socket) do def handle_event("select_image", %{"id" => id}, socket) do
@ -159,26 +181,29 @@ defmodule BerrypodWeb.Admin.Media do
# ── Private helpers ────────────────────────────────────────────── # ── Private helpers ──────────────────────────────────────────────
defp reload_images(socket) do defp image_filter_opts(socket) do
opts =
[ [
type: socket.assigns.filter_type, type: socket.assigns.filter_type,
search: if(socket.assigns.filter_search != "", do: socket.assigns.filter_search), search: if(socket.assigns.filter_search != "", do: socket.assigns.filter_search),
tag: nil tag: nil
] ]
|> Enum.reject(fn {_, v} -> is_nil(v) end) |> Enum.reject(fn {_, v} -> is_nil(v) end)
images = Media.list_images(opts)
images =
if socket.assigns.filter_orphans do
used = Media.used_image_ids()
Enum.reject(images, &MapSet.member?(used, &1.id))
else
images
end end
stream(socket, :images, images, reset: true) defp reload_images(socket) do
if socket.assigns.filter_orphans do
# Orphan mode: load all, filter in Elixir (no pagination)
opts = image_filter_opts(socket)
images = Media.list_images(opts)
used = Media.used_image_ids()
orphans = Enum.reject(images, &MapSet.member?(used, &1.id))
socket
|> assign(:pagination, nil)
|> stream(:images, orphans, reset: true)
else
push_patch(socket, to: ~p"/admin/media")
end
end end
defp format_file_size(nil), do: "" defp format_file_size(nil), do: ""
@ -284,7 +309,8 @@ defmodule BerrypodWeb.Admin.Media do
</div> </div>
<div class="media-main"> <div class="media-main">
<%!-- image grid --%> <%!-- image grid + pagination --%>
<div class="media-grid-wrapper">
<div id="media-grid" phx-update="stream" class="media-grid"> <div id="media-grid" phx-update="stream" class="media-grid">
<div <div
:for={{dom_id, image} <- @streams.images} :for={{dom_id, image} <- @streams.images}
@ -329,6 +355,9 @@ defmodule BerrypodWeb.Admin.Media do
</div> </div>
</div> </div>
<.admin_pagination :if={@pagination} page={@pagination} patch={~p"/admin/media"} />
</div>
<%!-- detail panel --%> <%!-- detail panel --%>
<div :if={@selected_image} class="media-detail-scrim" phx-click="deselect_image"></div> <div :if={@selected_image} class="media-detail-scrim" phx-click="deselect_image"></div>
<aside :if={@selected_image} class="media-detail"> <aside :if={@selected_image} class="media-detail">

View File

@ -6,8 +6,8 @@ defmodule BerrypodWeb.Admin.Newsletter do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
counts = Newsletter.count_subscribers_by_status() counts = Newsletter.count_subscribers_by_status()
subscribers = Newsletter.list_subscribers() sub_page = Newsletter.list_subscribers_paginated(page: 1)
campaigns = Newsletter.list_campaigns() camp_page = Newsletter.list_campaigns_paginated(page: 1)
{:ok, {:ok,
socket socket
@ -15,38 +15,47 @@ defmodule BerrypodWeb.Admin.Newsletter do
|> assign(:tab, "overview") |> assign(:tab, "overview")
|> assign(:newsletter_enabled, Newsletter.newsletter_enabled?()) |> assign(:newsletter_enabled, Newsletter.newsletter_enabled?())
|> assign(:status_counts, counts) |> assign(:status_counts, counts)
|> assign(:subscriber_count, length(subscribers)) |> assign(:subscriber_pagination, sub_page)
|> assign(:campaign_count, length(campaigns)) |> assign(:subscriber_count, sub_page.total_count)
|> assign(:campaign_pagination, camp_page)
|> assign(:campaign_count, camp_page.total_count)
|> assign(:status_filter, "all") |> assign(:status_filter, "all")
|> assign(:search, "") |> assign(:search, "")
|> stream(:subscribers, subscribers) |> stream(:subscribers, sub_page.items)
|> stream(:campaigns, campaigns)} |> stream(:campaigns, camp_page.items)}
end end
@impl true @impl true
def handle_params(%{"tab" => tab}, _uri, socket) def handle_params(params, _uri, socket) do
when tab in ~w(overview subscribers campaigns) do tab =
if params["tab"] in ~w(overview subscribers campaigns), do: params["tab"], else: "overview"
page_num = Berrypod.Pagination.parse_page(params)
socket = assign(socket, :tab, tab) socket = assign(socket, :tab, tab)
socket = socket =
case tab do case tab do
"subscribers" -> "subscribers" ->
subscribers = page =
Newsletter.list_subscribers( Newsletter.list_subscribers_paginated(
status: socket.assigns.status_filter, status: socket.assigns.status_filter,
search: socket.assigns.search search: socket.assigns.search,
page: page_num
) )
socket socket
|> assign(:subscriber_count, length(subscribers)) |> assign(:subscriber_pagination, page)
|> stream(:subscribers, subscribers, reset: true) |> assign(:subscriber_count, page.total_count)
|> stream(:subscribers, page.items, reset: true)
"campaigns" -> "campaigns" ->
campaigns = Newsletter.list_campaigns() page = Newsletter.list_campaigns_paginated(page: page_num)
socket socket
|> assign(:campaign_count, length(campaigns)) |> assign(:campaign_pagination, page)
|> stream(:campaigns, campaigns, reset: true) |> assign(:campaign_count, page.total_count)
|> stream(:campaigns, page.items, reset: true)
_ -> _ ->
socket socket
@ -55,8 +64,6 @@ defmodule BerrypodWeb.Admin.Newsletter do
{:noreply, socket} {:noreply, socket}
end end
def handle_params(_params, _uri, socket), do: {:noreply, socket}
@impl true @impl true
def handle_event("toggle_enabled", _params, socket) do def handle_event("toggle_enabled", _params, socket) do
new_value = !socket.assigns.newsletter_enabled new_value = !socket.assigns.newsletter_enabled
@ -71,23 +78,17 @@ defmodule BerrypodWeb.Admin.Newsletter do
end end
def handle_event("filter_subscribers", %{"status" => status}, socket) do def handle_event("filter_subscribers", %{"status" => status}, socket) do
subscribers = Newsletter.list_subscribers(status: status, search: socket.assigns.search)
{:noreply, {:noreply,
socket socket
|> assign(:status_filter, status) |> assign(:status_filter, status)
|> assign(:subscriber_count, length(subscribers)) |> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
|> stream(:subscribers, subscribers, reset: true)}
end end
def handle_event("search_subscribers", %{"search" => term}, socket) do def handle_event("search_subscribers", %{"search" => term}, socket) do
subscribers = Newsletter.list_subscribers(status: socket.assigns.status_filter, search: term)
{:noreply, {:noreply,
socket socket
|> assign(:search, term) |> assign(:search, term)
|> assign(:subscriber_count, length(subscribers)) |> push_patch(to: ~p"/admin/newsletter?tab=subscribers")}
|> stream(:subscribers, subscribers, reset: true)}
end end
def handle_event("delete_subscriber", %{"id" => id}, socket) do def handle_event("delete_subscriber", %{"id" => id}, socket) do
@ -152,12 +153,17 @@ defmodule BerrypodWeb.Admin.Newsletter do
status_filter={@status_filter} status_filter={@status_filter}
status_counts={@status_counts} status_counts={@status_counts}
subscriber_count={@subscriber_count} subscriber_count={@subscriber_count}
subscriber_pagination={@subscriber_pagination}
search={@search} search={@search}
/> />
</div> </div>
<div :if={@tab == "campaigns"}> <div :if={@tab == "campaigns"}>
<.campaigns_tab streams={@streams} campaign_count={@campaign_count} /> <.campaigns_tab
streams={@streams}
campaign_count={@campaign_count}
campaign_pagination={@campaign_pagination}
/>
</div> </div>
""" """
end end
@ -254,6 +260,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
attr :status_filter, :string, required: true attr :status_filter, :string, required: true
attr :status_counts, :map, required: true attr :status_counts, :map, required: true
attr :subscriber_count, :integer, required: true attr :subscriber_count, :integer, required: true
attr :subscriber_pagination, Berrypod.Pagination, required: true
attr :search, :string, required: true attr :search, :string, required: true
defp subscribers_tab(assigns) do defp subscribers_tab(assigns) do
@ -323,6 +330,13 @@ defmodule BerrypodWeb.Admin.Newsletter do
</:action> </:action>
</.table> </.table>
<.admin_pagination
:if={@subscriber_count > 0}
page={@subscriber_pagination}
patch={~p"/admin/newsletter"}
params={%{"tab" => "subscribers"}}
/>
<div :if={@subscriber_count == 0} class="text-center py-12 text-base-content/60"> <div :if={@subscriber_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-envelope" class="size-12 mx-auto mb-4" /> <.icon name="hero-envelope" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No subscribers yet</p> <p class="text-lg font-medium">No subscribers yet</p>
@ -336,6 +350,7 @@ defmodule BerrypodWeb.Admin.Newsletter do
attr :streams, :any, required: true attr :streams, :any, required: true
attr :campaign_count, :integer, required: true attr :campaign_count, :integer, required: true
attr :campaign_pagination, Berrypod.Pagination, required: true
defp campaigns_tab(assigns) do defp campaigns_tab(assigns) do
~H""" ~H"""
@ -370,6 +385,13 @@ defmodule BerrypodWeb.Admin.Newsletter do
</:action> </:action>
</.table> </.table>
<.admin_pagination
:if={@campaign_count > 0}
page={@campaign_pagination}
patch={~p"/admin/newsletter"}
params={%{"tab" => "campaigns"}}
/>
<div :if={@campaign_count == 0} class="text-center py-12 text-base-content/60"> <div :if={@campaign_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-megaphone" class="size-12 mx-auto mb-4" /> <.icon name="hero-megaphone" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No campaigns yet</p> <p class="text-lg font-medium">No campaigns yet</p>

View File

@ -7,32 +7,38 @@ defmodule BerrypodWeb.Admin.Orders do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
counts = Orders.count_orders_by_status() counts = Orders.count_orders_by_status()
orders = Orders.list_orders()
socket = socket =
socket socket
|> assign(:page_title, "Orders") |> assign(:page_title, "Orders")
|> assign(:status_filter, "all") |> assign(:status_filter, "all")
|> assign(:status_counts, counts) |> assign(:status_counts, counts)
|> assign(:order_count, length(orders))
|> stream(:orders, orders)
{:ok, socket} {:ok, socket}
end end
@impl true @impl true
def handle_event("filter", %{"status" => status}, socket) do def handle_params(params, _uri, socket) do
orders = Orders.list_orders(status: status) page_num = Berrypod.Pagination.parse_page(params)
page = Orders.list_orders_paginated(status: socket.assigns.status_filter, page: page_num)
socket = socket =
socket socket
|> assign(:status_filter, status) |> assign(:pagination, page)
|> assign(:order_count, length(orders)) |> assign(:order_count, page.total_count)
|> stream(:orders, orders, reset: true) |> stream(:orders, page.items, reset: true)
{:noreply, socket} {:noreply, socket}
end 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 @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -90,6 +96,8 @@ defmodule BerrypodWeb.Admin.Orders do
</:col> </:col>
</.table> </.table>
<.admin_pagination :if={@order_count > 0} page={@pagination} patch={~p"/admin/orders"} />
<div :if={@order_count == 0} class="text-center py-12 text-base-content/60"> <div :if={@order_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-inbox" class="size-12 mx-auto mb-4" /> <.icon name="hero-inbox" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No orders yet</p> <p class="text-lg font-medium">No orders yet</p>

View File

@ -9,55 +9,58 @@ defmodule BerrypodWeb.Admin.Products do
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
connections = Products.list_provider_connections() connections = Products.list_provider_connections()
categories = Products.list_all_categories() categories = Products.list_all_categories()
products = Products.list_products_admin()
socket = socket =
socket socket
|> assign(:page_title, "Products") |> assign(:page_title, "Products")
|> assign(:connections, connections) |> assign(:connections, connections)
|> assign(:categories, categories) |> assign(:categories, categories)
|> assign(:product_count, length(products))
|> assign(:provider_filter, "all") |> assign(:provider_filter, "all")
|> assign(:category_filter, "all") |> assign(:category_filter, "all")
|> assign(:visibility_filter, "all") |> assign(:visibility_filter, "all")
|> assign(:stock_filter, "all") |> assign(:stock_filter, "all")
|> assign(:sort, "newest") |> assign(:sort, "newest")
|> stream(:products, products)
{:ok, socket} {:ok, socket}
end end
@impl true @impl true
def handle_event("filter", params, socket) do def handle_params(params, _uri, socket) do
provider_filter = params["provider"] || socket.assigns.provider_filter page_num = Berrypod.Pagination.parse_page(params)
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
opts = opts =
[] build_filter_opts(
|> maybe_add_filter(:provider_connection_id, provider_filter) socket.assigns.provider_filter,
|> maybe_add_filter(:category, category_filter) socket.assigns.category_filter,
|> maybe_add_visibility(visibility_filter) socket.assigns.visibility_filter,
|> maybe_add_stock(stock_filter) socket.assigns.stock_filter,
|> Keyword.put(:sort, sort) socket.assigns.sort
)
products = Products.list_products_admin(opts) page = Products.list_products_admin_paginated([page: page_num] ++ opts)
socket = socket =
socket socket
|> assign(:provider_filter, provider_filter) |> assign(:pagination, page)
|> assign(:category_filter, category_filter) |> assign(:product_count, page.total_count)
|> assign(:visibility_filter, visibility_filter) |> stream(:products, page.items, reset: true)
|> assign(:stock_filter, stock_filter)
|> assign(:sort, sort)
|> assign(:product_count, length(products))
|> stream(:products, products, reset: true)
{:noreply, socket} {:noreply, socket}
end 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 @impl true
def handle_event("toggle_visibility", %{"id" => id}, socket) do def handle_event("toggle_visibility", %{"id" => id}, socket) do
product = product =
@ -177,6 +180,8 @@ defmodule BerrypodWeb.Admin.Products do
</:col> </:col>
</.table> </.table>
<.admin_pagination :if={@product_count > 0} page={@pagination} patch={~p"/admin/products"} />
<div :if={@product_count == 0} class="text-center py-12 text-base-content/60"> <div :if={@product_count == 0} class="text-center py-12 text-base-content/60">
<.icon name="hero-cube" class="size-12 mx-auto mb-4" /> <.icon name="hero-cube" class="size-12 mx-auto mb-4" />
<p class="text-lg font-medium">No products yet</p> <p class="text-lg font-medium">No products yet</p>
@ -272,6 +277,15 @@ defmodule BerrypodWeb.Admin.Products do
# Filter helpers # 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, "all"), do: opts
defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value) defp maybe_add_filter(opts, key, value), do: Keyword.put(opts, key, value)

View File

@ -9,11 +9,16 @@ defmodule BerrypodWeb.Admin.Redirects do
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
if connected?(socket), do: Redirects.subscribe() 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 =
socket socket
|> assign(:page_title, "Redirects") |> assign(:page_title, "Redirects")
|> assign(:redirects, Redirects.list_redirects()) |> assign(:redirect_pagination, redirect_page)
|> assign(:broken_urls, Redirects.list_broken_urls()) |> assign(:broken_url_pagination, broken_page)
|> stream(:redirects, redirect_page.items)
|> stream(:broken_urls, broken_page.items)
|> assign( |> assign(
:form, :form,
to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect) to_form(%{"from_path" => "", "to_path" => "", "status_code" => "301"}, as: :redirect)
@ -25,16 +30,50 @@ defmodule BerrypodWeb.Admin.Redirects do
@impl true @impl true
def handle_params(params, _uri, socket) do def handle_params(params, _uri, socket) do
tab = if params["tab"] in @valid_tabs, do: params["tab"], else: "redirects" 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 end
@impl true @impl true
def handle_info({:redirects_changed, _action}, socket) do 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 end
def handle_info({:broken_urls_changed, _path}, socket) do 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 end
@impl true @impl true
@ -43,10 +82,15 @@ defmodule BerrypodWeb.Admin.Redirects do
end end
def handle_event("delete_redirect", %{"id" => id}, socket) do def handle_event("delete_redirect", %{"id" => id}, socket) do
redirect = Redirects.get_redirect!(id) redirect_rec = Redirects.get_redirect!(id)
{:ok, _} = Redirects.delete_redirect(redirect) {: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 end
def handle_event("create_redirect", %{"redirect" => params}, socket) do 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) broken_url = Redirects.get_broken_url!(id)
{:ok, _} = Redirects.ignore_broken_url(broken_url) {: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 end
def handle_event("redirect_broken_url", %{"path" => path}, socket) do def handle_event("redirect_broken_url", %{"path" => path}, socket) do
@ -103,17 +152,27 @@ defmodule BerrypodWeb.Admin.Redirects do
</.header> </.header>
<div class="flex gap-2 mt-6 mb-4 flex-wrap"> <div class="flex gap-2 mt-6 mb-4 flex-wrap">
<.tab_button tab="redirects" label="Active" count={length(@redirects)} active={@tab} /> <.tab_button
<.tab_button tab="broken" label="Broken URLs" count={length(@broken_urls)} active={@tab} /> tab="redirects"
label="Active"
count={@redirect_pagination.total_count}
active={@tab}
/>
<.tab_button
tab="broken"
label="Broken URLs"
count={@broken_url_pagination.total_count}
active={@tab}
/>
<.tab_button tab="create" label="Create" active={@tab} /> <.tab_button tab="create" label="Create" active={@tab} />
</div> </div>
<%= if @tab == "redirects" do %> <%= if @tab == "redirects" do %>
<.redirects_table redirects={@redirects} /> <.redirects_table streams={@streams} pagination={@redirect_pagination} />
<% end %> <% end %>
<%= if @tab == "broken" do %> <%= if @tab == "broken" do %>
<.broken_urls_table broken_urls={@broken_urls} /> <.broken_urls_table streams={@streams} pagination={@broken_url_pagination} />
<% end %> <% end %>
<%= if @tab == "create" do %> <%= if @tab == "create" do %>
@ -124,9 +183,10 @@ defmodule BerrypodWeb.Admin.Redirects do
defp redirects_table(assigns) do defp redirects_table(assigns) do
~H""" ~H"""
<%= if @redirects == [] do %> <%= if @pagination.total_count == 0 do %>
<p>No redirects yet.</p> <p>No redirects yet.</p>
<% else %> <% else %>
<div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
@ -138,9 +198,8 @@ defmodule BerrypodWeb.Admin.Redirects do
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="redirects-table" phx-update="stream">
<%= for redirect <- @redirects do %> <tr :for={{dom_id, redirect} <- @streams.redirects} id={dom_id}>
<tr>
<td><code>{redirect.from_path}</code></td> <td><code>{redirect.from_path}</code></td>
<td><code>{redirect.to_path}</code></td> <td><code>{redirect.to_path}</code></td>
<td> <td>
@ -161,18 +220,25 @@ defmodule BerrypodWeb.Admin.Redirects do
</button> </button>
</td> </td>
</tr> </tr>
<% end %>
</tbody> </tbody>
</table> </table>
</div>
<.admin_pagination
page={@pagination}
patch={~p"/admin/redirects"}
params={%{"tab" => "redirects"}}
/>
<% end %> <% end %>
""" """
end end
defp broken_urls_table(assigns) do defp broken_urls_table(assigns) do
~H""" ~H"""
<%= if @broken_urls == [] do %> <%= if @pagination.total_count == 0 do %>
<p>No broken URLs detected.</p> <p>No broken URLs detected.</p>
<% else %> <% else %>
<div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
<tr> <tr>
@ -184,9 +250,8 @@ defmodule BerrypodWeb.Admin.Redirects do
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="broken-urls-table" phx-update="stream">
<%= for broken_url <- @broken_urls do %> <tr :for={{dom_id, broken_url} <- @streams.broken_urls} id={dom_id}>
<tr>
<td><code>{broken_url.path}</code></td> <td><code>{broken_url.path}</code></td>
<td>{broken_url.prior_analytics_hits}</td> <td>{broken_url.prior_analytics_hits}</td>
<td>{broken_url.recent_404_count}</td> <td>{broken_url.recent_404_count}</td>
@ -209,9 +274,15 @@ defmodule BerrypodWeb.Admin.Redirects do
</button> </button>
</td> </td>
</tr> </tr>
<% end %>
</tbody> </tbody>
</table> </table>
</div>
<.admin_pagination
page={@pagination}
patch={~p"/admin/redirects"}
params={%{"tab" => "broken"}}
/>
<% end %> <% end %>
""" """
end end

View File

@ -1,7 +1,7 @@
defmodule BerrypodWeb.Shop.Collection do defmodule BerrypodWeb.Shop.Collection do
use BerrypodWeb, :live_view use BerrypodWeb, :live_view
alias Berrypod.{Pages, Products} alias Berrypod.{Pages, Pagination, Products}
@sort_options [ @sort_options [
{"featured", "Featured"}, {"featured", "Featured"},
@ -28,18 +28,21 @@ defmodule BerrypodWeb.Shop.Collection do
@impl true @impl true
def handle_params(%{"slug" => slug} = params, _uri, socket) do def handle_params(%{"slug" => slug} = params, _uri, socket) do
sort = params["sort"] || "featured" sort = params["sort"] || "featured"
page_num = Pagination.parse_page(params)
case load_collection(slug, sort) do case load_collection(slug, sort, page_num) do
{:ok, title, category, products} -> {:ok, title, category, pagination} ->
{:noreply, {:noreply,
socket socket
|> assign(:page_title, title) |> assign(:page_title, title)
|> assign(:page_description, collection_description(title)) |> assign(:page_description, collection_description(title))
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}") |> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/collections/#{slug}")
|> assign(:collection_title, title) |> assign(:collection_title, title)
|> assign(:collection_slug, slug)
|> assign(:current_category, category) |> assign(:current_category, category)
|> assign(:current_sort, sort) |> assign(:current_sort, sort)
|> assign(:products, products)} |> assign(:pagination, pagination)
|> assign(:products, pagination.items)}
:not_found -> :not_found ->
{:noreply, {:noreply,
@ -49,22 +52,30 @@ defmodule BerrypodWeb.Shop.Collection do
end end
end end
defp load_collection("all", sort) do defp load_collection("all", sort, page) do
{:ok, "All Products", nil, Products.list_visible_products(sort: sort)} pagination = Products.list_visible_products_paginated(sort: sort, page: page)
{:ok, "All Products", nil, pagination}
end end
defp load_collection("sale", sort) do defp load_collection("sale", sort, page) do
{:ok, "Sale", :sale, Products.list_visible_products(on_sale: true, sort: sort)} pagination = Products.list_visible_products_paginated(on_sale: true, sort: sort, page: page)
{:ok, "Sale", :sale, pagination}
end end
defp load_collection(slug, sort) do defp load_collection(slug, sort, page) do
case Enum.find(Products.list_categories(), &(&1.slug == slug)) do case Enum.find(Products.list_categories(), &(&1.slug == slug)) do
nil -> nil ->
:not_found :not_found
category -> category ->
products = Products.list_visible_products(category: category.name, sort: sort) pagination =
{:ok, category.name, category, products} Products.list_visible_products_paginated(
category: category.name,
sort: sort,
page: page
)
{:ok, category.name, category, pagination}
end end
end end

View File

@ -439,10 +439,19 @@ defmodule BerrypodWeb.PageRenderer do
# ── Collection blocks ─────────────────────────────────────────── # ── Collection blocks ───────────────────────────────────────────
defp render_block(%{block: %{"type" => "collection_header"}} = assigns) do defp render_block(%{block: %{"type" => "collection_header"}} = assigns) do
count =
if assigns[:pagination] do
assigns.pagination.total_count
else
length(assigns[:products] || [])
end
assigns = assign(assigns, :product_count, count)
~H""" ~H"""
<.collection_header <.collection_header
title={assigns[:collection_title] || "All Products"} title={assigns[:collection_title] || "All Products"}
product_count={length(assigns[:products] || [])} product_count={@product_count}
/> />
""" """
end end
@ -525,7 +534,15 @@ defmodule BerrypodWeb.PageRenderer do
defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do defp render_block(%{block: %{"type" => "product_grid"}} = assigns) do
show_category = assigns[:current_category] in [nil, :sale] show_category = assigns[:current_category] in [nil, :sale]
assigns = assign(assigns, :show_category, show_category) collection_slug = assigns[:collection_slug] || "all"
current_sort = assigns[:current_sort] || "featured"
sort_params = if current_sort != "featured", do: %{"sort" => current_sort}, else: %{}
assigns =
assigns
|> assign(:show_category, show_category)
|> assign(:collection_slug, collection_slug)
|> assign(:sort_params, sort_params)
~H""" ~H"""
<div class="page-container"> <div class="page-container">
@ -541,6 +558,13 @@ defmodule BerrypodWeb.PageRenderer do
<% end %> <% end %>
</.product_grid> </.product_grid>
<.shop_pagination
:if={assigns[:pagination] && assigns[:pagination].total_pages > 1}
page={assigns[:pagination]}
base_path={~p"/collections/#{@collection_slug}"}
params={@sort_params}
/>
<%= if (assigns[:products] || []) == [] do %> <%= if (assigns[:products] || []) == [] do %>
<div class="collection-empty"> <div class="collection-empty">
<p>No products found in this collection.</p> <p>No products found in this collection.</p>

399
priv/repo/dev_seeds.exs Normal file
View File

@ -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

View File

@ -84,7 +84,8 @@ defmodule Berrypod.Newsletter.NotifierTest do
describe "deliver_test/2" do describe "deliver_test/2" do
test "sends test email with [Test] prefix in subject" 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") assert {:ok, _} = Notifier.deliver_test(campaign, "admin@example.com")

View File

@ -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